diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 0c5a715..7dd4b58 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -2,7 +2,7 @@ ## 1. Overview -This document specifies the `/tether` mobile terminal prototype. +This document specifies the `/playground/pocket` mobile terminal prototype. The prototype tests one core idea: @@ -13,13 +13,17 @@ Stable terminal viewport + mobile session viewport + explicit touch mode + expli The app should feel like a lightweight mobile terminal playground. It does not need remote sessions, SSH, user accounts, or production infrastructure. -The website `/tether` prototype exposes a small floating theme switcher above -the terminal. It uses the shared Dormouse `ThemePicker`. +The website `/playground/pocket` prototype exposes a small floating theme +switcher above the terminal. It uses the shared Dormouse `ThemePicker`. On +desktop, `/playground/pocket` shows a share-to-phone page instead of the +interactive terminal. The `/pocket` route temporarily redirects to +`/playground/pocket`; this is a launch-state redirect, not the future real +tethering environment. -`/tether` uses the same fake playground terminal stack as `/playground`: -`PlaygroundShellRegistry` attaches a `TutorialShell` to every spawned pane, the -same fake commands dispatch to browser-side runners, and the first pane simply -auto-runs `ascii-splash` as its initial command. +`/playground/pocket` uses the same fake playground terminal stack as +`/playground/desktop`: `PlaygroundShellRegistry` attaches a `TutorialShell` to +every spawned pane, the same fake commands dispatch to browser-side runners, and +the first pane simply auto-runs `ascii-splash` as its initial command. ## 2. Prototype Goals @@ -76,10 +80,10 @@ block should use one divider between the Touch and Input rows, with no divider above Touch and no divider below Input. The mobile session header should not use the desktop terminal title corner radius; it is a flush mobile bar. The alert bell sits immediately after the title before secondary title detail. The mobile -header keeps a minimize button, and in the `/tether` prototype that action opens -the Sessions reserve instead of creating a desktop Door. The Touch row and its -selector tray should sit on `terminal-bg` so they read as part of the terminal -surface above. The Input row and reserve area should sit on +header keeps a minimize button, and in the `/playground/pocket` prototype that +action opens the Sessions reserve instead of creating a desktop Door. The Touch +row and its selector tray should sit on `terminal-bg` so they read as part of +the terminal surface above. The Input row and reserve area should sit on `header-inactive-bg` with `header-inactive-fg`, so the lower input controls are distinct from the terminal while still following the selected theme. @@ -111,8 +115,8 @@ If Mouse mode is active and the active pane stops capturing mouse events, the selector must fall back to Gestures. Gesture mode intentionally consumes primary mouse/trackpad clicks in addition to -touch input. This keeps the `/tether` prototype usable in desktop browsers, -narrow desktop viewports, and Storybook without a touchscreen. A primary +touch input. This keeps the `/playground/pocket` prototype usable in desktop +browsers, narrow desktop viewports, and Storybook without a touchscreen. A primary mouse/trackpad click in pane content must start radial gesture handling, call `preventDefault()`, stop propagation, and capture that pointer; it is not passed through to the embedded `Wall`, xterm, or dockview for focus, selection, or pane @@ -376,7 +380,7 @@ Minimum useful behavior: `tut` is running, `Ctrl+C` sends `\x03` to that app; if the app exits, the terminal returns to the fake shell prompt instead of restarting the app. * New panes created from the wall get the same fake shell behavior and prompt as - regular `/playground` panes. + regular `/playground/desktop` panes. Example commands: diff --git a/docs/specs/theme.md b/docs/specs/theme.md index 2e01c91..037e353 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -152,11 +152,13 @@ terminal colors. It captures the current DOM-visible theme state and shows: - dynamic door/focus-ring picks from the same `pickDoorPair()` and `pickFocusRing()` helpers used by Wall's `computeDynamicPalette()`. -Standalone, playground, and the website `/tether` prototype expose the debugger -as `Debug current theme` in the `ThemePicker` menu. `/tether` uses the same -picker in the desktop page header and as a floating control above the mobile -terminal prototype, both with the Kimbie Dark default theme fallback. VSCode -opens it through the `dormouse.debugTheme` command and the +Standalone, playground, and the website `/playground/pocket` prototype expose +the debugger as `Debug current theme` in the `ThemePicker` menu. +`/playground/pocket` uses the same picker in the desktop share page header and +as a floating control above the mobile terminal prototype, both with the Kimbie +Dark default theme fallback. `/pocket` redirects before rendering a picker. +VSCode opens it through the +`dormouse.debugTheme` command and the `dormouse:openThemeDebugger` extension-to-webview message. The debugger's copied report is a shareable text dump of the same snapshot. diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index b9b0544..b46391c 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -1,6 +1,13 @@ # Playground Tutorial -At the `/playground` route on the website. Interactive TUI: each item starts pending, the first incomplete item is marked as active, and completed items become green checks when Dormouse detects the corresponding action. +The website playground has canonical device-specific routes: + +- `/playground` is a client-side dispatcher. It picks Pocket for coarse-pointer devices or narrow viewports and Desktop otherwise, then replaces the history entry with `/playground/desktop` or `/playground/pocket`. The exact media query lives in `website/src/lib/playground-routing.ts`. +- `/playground/desktop` hosts the desktop tiling tutorial. When the dispatcher would have picked Pocket (coarse pointer or narrow viewport) it does not mount `Wall`; it shows a message that the screen is too small for the desktop playground and links to `/playground/pocket`. +- `/playground/pocket` hosts the mobile Pocket playground. On desktop it shows the temporary Pocket marketing/share page from the old `/pocket` route, including the phone preview and notify signup form. +- `/pocket` temporarily redirects to `/playground/pocket`. This is a temporary launch-state redirect; the future real tethering surface should stay separate from the playground URL when it exists. + +The interactive desktop TUI lives at `/playground/desktop`. Each item starts pending, the first incomplete item is marked as active, and completed items become green checks when Dormouse detects the corresponding action. ## Architecture @@ -13,16 +20,12 @@ Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `websi ## Layout -- `SiteHeader` at top with the `Theme:` dropdown control on `/playground` (other routes do not render it). Header is `themeAware` so `--vscode-*` variables drive its background, border, text, and banner colors. +- `SiteHeader` at top with the `Theme:` dropdown control on `/playground/desktop`. Header is `themeAware` so `--vscode-*` variables drive its background, border, text, and banner colors. - `
` is a flex container so Wall's `flex-1 min-h-0` root gets a real height. -- `Wall` runs `FakePtyAdapter` with `initialMode="passthrough"`. The pane layout branches at mount on `window.innerWidth < 768` (Tailwind's `md` breakpoint, locked at mount; not reactive to resize): - - **Desktop (≥ 768px)** — three panes: - - **`tut-main`** (left, ~50%) — auto-launches `TutRunner` via `mainShell.runCommand("tut")`. - - **`tut-boxed`** (right-top, ~25%) — titled "changelog". Auto-launches `ChangelogRunner` via `boxedShell.runCommand("changelog")`. Doubles as the Copy Rewrapped target — its wrapped lines exercise the rewrap path. - - **`tut-splash`** (right-bottom, ~25%) — titled "ascii-splash". Auto-launches `AsciiSplashRunner` via `splashShell.runCommand("ascii-splash")`. - - **Phone (< 768px)** — two stacked panes; the changelog is dropped because the screen is too narrow to host it usefully: - - **`tut-main`** (top, ~50%) — same as desktop. - - **`tut-splash`** (bottom, ~50%) — same as desktop. +- `Wall` runs `FakePtyAdapter` with `initialMode="passthrough"` on `/playground/desktop`. The route uses the desktop three-pane layout only: + - **`tut-main`** (left, ~50%) — auto-launches `TutRunner` via `mainShell.runCommand("tut")`. + - **`tut-boxed`** (right-top, ~25%) — titled "changelog". Auto-launches `ChangelogRunner` via `boxedShell.runCommand("changelog")`. Doubles as the Copy Rewrapped target — its wrapped lines exercise the rewrap path. + - **`tut-splash`** (right-bottom, ~25%) — titled "ascii-splash". Auto-launches `AsciiSplashRunner` via `splashShell.runCommand("ascii-splash")`. - Side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane. Every playground pane gets a `TutorialShell` input handler through `PlaygroundShellRegistry`. Newly split or spawned fake terminals use `SCENARIO_SHELL_PROMPT` by default. The shell dispatches by command name to a `startProgram` factory provided by the page; the factory wires `tut` → `TutRunner` and `ascii-splash` / `splash` → `AsciiSplashRunner`. @@ -31,9 +34,9 @@ Every playground pane gets a `TutorialShell` input handler through `PlaygroundSh The runner shows a top-level menu first. Selecting a section drills into its item list. Each section shows `[N/M complete]` next to its title. The menu helper below `Dormouse Playground Tutorial` shows only navigation shortcuts, not overall completion. -The top-level menu also includes `Starred on GitHub`, which sits directly below `Copy paste` without a blank spacer, and shows `[not yet]` until selected and `[thanks ⭐]` after it has been resolved. Pressing Enter on that row calls `onOpenGithub`, which `/playground` and the mobile tether page wire to `window.open("https://github.com/diffplug/dormouse", "_blank", "noopener,noreferrer")`. +The top-level menu also includes `Starred on GitHub`, which sits directly below `Copy paste` without a blank spacer, and shows `[not yet]` until selected and `[thanks ⭐]` after it has been resolved. Pressing Enter on that row calls `onOpenGithub`, which `/playground/desktop` and the Pocket playground wire to `window.open("https://github.com/diffplug/dormouse", "_blank", "noopener,noreferrer")`. -After `Starred on GitHub`, the top-level menu shows the mystery row. It is `🐭 ??? 🐭` with `[LOCKED N/M]` while any section task is incomplete. `N/M` is computed from section checklist items only; `Starred on GitHub` and the mystery row do not count. When all section tasks are complete, the row becomes `🐭 Flappy Term 🐭` with a `[High score: N]` readout. Pressing Enter on the unlocked row opens Flappy Term, a runner-local mini-game: `Space`/`Up`/`Enter` flaps the bird, scoring persists as the high score, and `Esc` returns to the top-level menu. On the game-over screen, `Enter` restarts and `p` calls `onOpenPocket`, which `/playground` and the mobile tether page wire to `window.open("https://dormouse.sh/pocket", "_blank", "noopener,noreferrer")`. +After `Starred on GitHub`, the top-level menu shows the mystery row. It is `🐭 ??? 🐭` with `[LOCKED N/M]` while any section task is incomplete. `N/M` is computed from section checklist items only; `Starred on GitHub` and the mystery row do not count. When all section tasks are complete, the row becomes `🐭 Flappy Term 🐭` with a `[High score: N]` readout. Pressing Enter on the unlocked row opens Flappy Term, a runner-local mini-game: `Space`/`Up`/`Enter` flaps the bird, scoring persists as the high score, and `Esc` returns to the top-level menu. On the game-over screen, `Enter` restarts and `p` calls `onOpenPocket`, which `/playground/desktop` and the Pocket playground wire to `window.open("/pocket", "_blank", "noopener,noreferrer")`. The game-over prompt reads `Read about Dormouse Pocket [p]`. Inside a section, items render as one of: @@ -117,14 +120,14 @@ Implemented in `dormouse-lib/lib/themes` and `dormouse-lib/components/ThemePicke Bundled themes are provided by `dormouse-lib/lib/themes` and include only GitHub variants. Users can install additional themes from OpenVSX through the dropdown footer action. -The picker appears only on `/playground`, inside `SiteHeader`, labeled `Theme:`. The trigger opens a dropdown of bundled and installed themes. The dropdown footer is always `Install theme from OpenVSX`, which opens the theme store dialog. Installed theme rows include an `X` delete control; deletion requires browser confirmation before removing the theme from localStorage. If the active installed theme is deleted, the picker falls back to the first bundled theme and applies it immediately. +The picker appears on `/playground/desktop` and `/playground/pocket`, labeled `Theme:`. On `/playground/desktop` it is inside the theme-aware `SiteHeader`; on `/playground/pocket` mobile it floats over the terminal; on the desktop Pocket playground page it uses the standalone appbar variant. `/pocket` redirects before rendering a picker. The trigger opens a dropdown of bundled and installed themes. The dropdown footer is always `Install theme from OpenVSX`, which opens the theme store dialog. Installed theme rows include an `X` delete control; deletion requires browser confirmation before removing the theme from localStorage. If the active installed theme is deleted, the picker falls back to the first bundled theme and applies it immediately. Each theme is defined as a map of `--vscode-*` CSS variable overrides. `applyTheme()` applies the active theme, which: 1. Cascades into `--color-*` variables (via `var(--vscode-*, fallback)` in `theme.css`) 2. Triggers the `MutationObserver` in `lib/src/lib/terminal-theme.ts` to re-read `getTerminalTheme()` for all xterm.js terminals 3. Updates Dockview/Tailwind token colors -The picker restores the persisted active theme on mount. The playground header is `themeAware`, so the same active theme also affects the site header chrome while the picker remains hidden on non-playground routes. +The picker restores the persisted active theme on mount. The desktop playground header is `themeAware`, so the same active theme also affects that route's site header chrome. ## Mouse and Clipboard Feature Coverage diff --git a/website/src/App.tsx b/website/src/App.tsx index 94fba27..a3ece99 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -9,6 +9,14 @@ export const routes: RouteRecord[] = [ path: "/playground", lazy: () => import("./pages/Playground"), }, + { + path: "/playground/desktop", + lazy: () => import("./pages/PlaygroundDesktop"), + }, + { + path: "/playground/pocket", + lazy: () => import("./pages/PocketPlayground"), + }, { path: "/pocket", lazy: () => import("./pages/Pocket"), diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx new file mode 100644 index 0000000..48acb1e --- /dev/null +++ b/website/src/components/PocketTerminalExperience.tsx @@ -0,0 +1,199 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { MobileTerminalUi, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode } from "dormouse-lib/components/MobileTerminalUi"; +import { MobileWall, useMobileWallSessionItems, type MobileWallSession } from "dormouse-lib/components/MobileWall"; +import { restoreActiveTheme } from "dormouse-lib/lib/themes"; +import { + getMouseSelectionSnapshot, + setOverride as setMouseOverride, + subscribeToMouseSelection, +} from "dormouse-lib/lib/mouse-selection"; +import { PlaygroundShellRegistry } from "../lib/playground-shells"; +import { TutorialState } from "../lib/tutorial-state"; +import { BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, TutRunner } from "../lib/tut-runner"; +import { ChangelogRunner } from "../lib/changelog-runner"; +import { POCKET_PLAYGROUND_PATH } from "../lib/playground-routing"; + +export const POCKET_THEME_ID = "vscode.theme-kimbie-dark.kimbie-dark"; + +type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; + +const POCKET_PANE = "pocket-ascii-splash"; +const POCKET_SESSIONS: MobileWallSession[] = [{ id: POCKET_PANE, title: "ascii-splash" }]; + +function usePocketTheme() { + const restoredRef = useRef(false); + if (!restoredRef.current) { + restoreActiveTheme(POCKET_THEME_ID); + restoredRef.current = true; + } +} + +export function PocketTerminalExperience({ + interactive, + fillViewport = false, +}: { + interactive: boolean; + fillViewport?: boolean; +}) { + usePocketTheme(); + const [terminalReady, setTerminalReady] = useState(false); + const adapterRef = useRef(null); + const shellRegistryRef = useRef(null); + const autoStartedRef = useRef>(new Set()); + const spawnUnsubRef = useRef<(() => void) | null>(null); + const busyDemoDisposeRef = useRef<(() => void) | null>(null); + const [activePaneId, setActivePaneId] = useState(POCKET_PANE); + const [touchMode, setTouchMode] = useState("gestures"); + const [keyboardMode, setKeyboardMode] = useState("type"); + const sessionItems = useMobileWallSessionItems(POCKET_SESSIONS, activePaneId); + const mouseStates = useSyncExternalStore( + subscribeToMouseSelection, + getMouseSelectionSnapshot, + getMouseSelectionSnapshot, + ); + const activeMouseState = mouseStates.get(activePaneId); + const cursorTouchAvailable = activeMouseState?.mouseReporting !== undefined + && activeMouseState.mouseReporting !== "none"; + + const handleOpenGithub = useCallback(() => { + window.open( + "https://github.com/diffplug/dormouse", + "_blank", + "noopener,noreferrer", + ); + }, []); + + const handleOpenPocket = useCallback(() => { + window.open(POCKET_PLAYGROUND_PATH, "_blank", "noopener,noreferrer"); + }, []); + + const tryAutoStart = useCallback((id: string) => { + if (id !== POCKET_PANE) return; + if (autoStartedRef.current.has(id)) return; + const shellRegistry = shellRegistryRef.current; + if (!shellRegistry) return; + autoStartedRef.current.add(id); + shellRegistry.ensureShell(id).runCommand("ascii-splash"); + }, []); + + useEffect(() => { + let cancelled = false; + + async function loadWall() { + const platform = await import("dormouse-lib/lib/platform"); + const registry = await import("dormouse-lib/lib/terminal-registry"); + const scenarios = await import("dormouse-lib/lib/platform/fake-scenarios"); + const asciiSplash = await import("../lib/ascii-splash-runner"); + await import("dormouse-lib/index.css"); + if (cancelled) return; + + const adapter = platform.initPlatform("fake"); + registry.disposeAllSessions(); + adapter.reset(); + registry.initAlertStateReceiver(); + adapterRef.current = adapter; + adapter.setDefaultScenario(scenarios.SCENARIO_SHELL_PROMPT); + adapter.setScenario(POCKET_PANE, { name: "none", chunks: [] }); + + const tutorialState = new TutorialState(); + const shellRegistry = new PlaygroundShellRegistry( + adapter, + (terminalId, name, args, onExit) => { + if (name === "tut") { + return new TutRunner({ + adapter, + terminalId, + state: tutorialState, + onExit, + onTriggerBusyDemo: () => { + busyDemoDisposeRef.current?.(); + busyDemoDisposeRef.current = adapter.pumpActivity( + terminalId, + BUSY_DEMO_DURATION_MS, + BUSY_DEMO_INTERVAL_MS, + ); + }, + onTogglePlaceToPaste: () => {}, + onOpenGithub: handleOpenGithub, + onOpenPocket: handleOpenPocket, + }); + } + if (name === "ascii-splash" || name === "splash") { + return new asciiSplash.AsciiSplashRunner({ + adapter, + terminalId, + args, + onExit, + }); + } + if (name === "changelog") { + return new ChangelogRunner({ adapter, terminalId, onExit }); + } + return null; + }, + ); + shellRegistryRef.current = shellRegistry; + shellRegistry.ensureShell(POCKET_PANE); + + spawnUnsubRef.current = adapter.onPtySpawn(({ id }) => { + shellRegistry.ensureShell(id); + tryAutoStart(id); + }); + if (adapter.hasPty(POCKET_PANE)) tryAutoStart(POCKET_PANE); + + setTerminalReady(true); + } + + loadWall(); + + return () => { + cancelled = true; + spawnUnsubRef.current?.(); + spawnUnsubRef.current = null; + busyDemoDisposeRef.current?.(); + busyDemoDisposeRef.current = null; + shellRegistryRef.current?.disposeAll(); + shellRegistryRef.current = null; + autoStartedRef.current.clear(); + adapterRef.current = null; + }; + }, [handleOpenGithub, handleOpenPocket, tryAutoStart]); + + useEffect(() => { + const reporting = activeMouseState?.mouseReporting ?? "none"; + if (touchMode === "selection" && reporting !== "none") { + setMouseOverride(activePaneId, "permanent"); + } else { + setMouseOverride(activePaneId, "off"); + } + }, [activeMouseState?.mouseReporting, activePaneId, touchMode]); + + return ( + setKeyboardMode("sessions")} + /> + ) : null + } + interactive={interactive} + fillViewport={fillViewport} + activeTouchMode={touchMode} + onTouchModeChange={setTouchMode} + activeKeyboardMode={keyboardMode} + onKeyboardModeChange={setKeyboardMode} + cursorTouchAvailable={cursorTouchAvailable} + sessions={sessionItems} + onSessionSelect={setActivePaneId} + onSendInput={(data) => adapterRef.current?.writePty(activePaneId, data)} + onPaste={async () => { + const { doPaste } = await import("dormouse-lib/lib/clipboard"); + await doPaste(activePaneId); + }} + /> + ); +} diff --git a/website/src/components/ShareUrlButton.tsx b/website/src/components/ShareUrlButton.tsx new file mode 100644 index 0000000..e13d5f6 --- /dev/null +++ b/website/src/components/ShareUrlButton.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from "react"; +import { ShareIcon } from "@phosphor-icons/react"; + +interface ShareUrlButtonProps { + path?: string; + title: string; + children?: React.ReactNode; +} + +export function ShareUrlButton({ path, title, children }: ShareUrlButtonProps) { + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (copiedTimerRef.current !== null) { + window.clearTimeout(copiedTimerRef.current); + } + }; + }, []); + + async function handleShare() { + const url = path + ? new URL(`${path}${window.location.search}${window.location.hash}`, window.location.origin).href + : window.location.href; + if (navigator.share) { + try { + await navigator.share({ url, title }); + return; + } catch (err) { + if ((err as DOMException)?.name === "AbortError") return; + } + } + try { + await navigator.clipboard.writeText(url); + setCopied(true); + if (copiedTimerRef.current !== null) { + window.clearTimeout(copiedTimerRef.current); + } + copiedTimerRef.current = window.setTimeout(() => { + copiedTimerRef.current = null; + setCopied(false); + }, 2000); + } catch { + window.prompt("Copy this URL to your phone:", url); + } + } + + return ( + + ); +} diff --git a/website/src/index.css b/website/src/index.css index baa4414..8764e1c 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -24,9 +24,10 @@ html { } /* Override lib's terminal-app styles (body overflow:hidden, #root height:100vh, - body font-family system font) which Vite loads globally. The Playground page - re-applies them when it mounts. Higher specificity (html body) is required so - we beat the lib's `body` rule which loads after ours on /playground. */ + body font-family system font) which Vite loads globally. The terminal + playground routes re-apply them when they mount. Higher specificity + (html body) is required so we beat the lib's `body` rule after those routes + import dormouse-lib/index.css. */ html body { overflow: auto; font-family: var(--font-body); diff --git a/website/src/lib/playground-routing.ts b/website/src/lib/playground-routing.ts new file mode 100644 index 0000000..7bab740 --- /dev/null +++ b/website/src/lib/playground-routing.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; + +export const DESKTOP_PLAYGROUND_PATH = "/playground/desktop"; +export const POCKET_PLAYGROUND_PATH = "/playground/pocket"; + +export type PreferredPlayground = "desktop" | "pocket"; + +const POCKET_PLAYGROUND_QUERY = "(max-width: 700px), (pointer: coarse)"; + +function getPreferredPlayground(): PreferredPlayground { + if (typeof window === "undefined") return "desktop"; + return window.matchMedia(POCKET_PLAYGROUND_QUERY).matches ? "pocket" : "desktop"; +} + +export function usePreferredPlayground() { + const [preferred, setPreferred] = useState(getPreferredPlayground); + + useEffect(() => { + const media = window.matchMedia(POCKET_PLAYGROUND_QUERY); + const update = () => setPreferred(getPreferredPlayground()); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + + return preferred; +} diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index c335794..0551068 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -724,7 +724,7 @@ export class TutRunner implements InteractiveProgram { out += this.flappyCenteredAt( COLS, r + 8, - "Checkout dormouse.sh/pocket to play on your phone [p]", + "Read about Dormouse Pocket [p]", fg256(C.text), ); } else if (!g.started) { diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx index acd9d99..6c0e390 100644 --- a/website/src/pages/Home.tsx +++ b/website/src/pages/Home.tsx @@ -941,7 +941,7 @@ function Home() { Walk away. Keep going.

- Coming next: Dormouse Pocket. + Coming next: Dormouse Pocket. Tether a terminal session to your phone over WebRTC and take a stroll — Dormouse buzzes your phone when something needs attention. A hosted auto-pairing service comes later, so you can close the laptop and walk away, no setup dance. diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index 54439f2..5ba1850 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -1,267 +1,31 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import SiteHeader from "../components/SiteHeader"; -import { PlaceToPaste } from "../components/PlaceToPaste"; -import { ThemePicker } from "dormouse-lib/components/ThemePicker"; -import { PlaygroundShellRegistry } from "../lib/playground-shells"; -import { TutorialState } from "../lib/tutorial-state"; -import { TutDetector } from "../lib/tut-detector"; -import { BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, TutRunner } from "../lib/tut-runner"; -import { ChangelogRunner } from "../lib/changelog-runner"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { + DESKTOP_PLAYGROUND_PATH, + POCKET_PLAYGROUND_PATH, + usePreferredPlayground, +} from "../lib/playground-routing"; -export { Playground as Component }; +export { PlaygroundRedirect as Component }; -const PANE_MAIN = "tut-main"; -const PANE_BOXED = "tut-boxed"; -const PANE_SPLASH = "tut-splash"; - -type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; -type WallEvent = import("dormouse-lib/components/Wall").WallEvent; -type DockviewDisposable = { dispose: () => void }; - -// Tailwind's md breakpoint — matches the header's `md:top-20` so the pane -// area below begins at the same threshold. Locked at mount, not reactive -// to resize. -const isPhoneAtMount = () => - typeof window !== "undefined" && window.innerWidth < 768; - -interface PaneSpec { - id: string; - command: string; -} - -function Playground() { - const [WallModule, setWallModule] = useState<{ - Wall: React.ComponentType; - } | null>(null); - const [placeToPasteOpen, setPlaceToPasteOpen] = useState(false); - // Phone: tutorial on top, ascii-splash below. Desktop: tutorial left, - // changelog top-right, ascii-splash bottom-right. - const [isPhone] = useState(isPhoneAtMount); - - const adapterRef = useRef(null); - const shellRegistryRef = useRef(null); - const detectorRef = useRef(null); - const stateRef = useRef(null); - const dockviewDisposablesRef = useRef([]); - const autoStartedRef = useRef>(new Set()); - const spawnUnsubRef = useRef<(() => void) | null>(null); - const busyDemoDisposeRef = useRef<(() => void) | null>(null); - const alertDemoPaneIdRef = useRef(null); - - const handleOpenGithub = useCallback(() => { - window.open( - "https://github.com/diffplug/dormouse", - "_blank", - "noopener,noreferrer", - ); - }, []); - - const handleOpenPocket = useCallback(() => { - window.open("https://dormouse.sh/pocket", "_blank", "noopener,noreferrer"); - }, []); - - const tryAutoStart = useCallback((pane: PaneSpec) => { - if (autoStartedRef.current.has(pane.id)) return; - const shellRegistry = shellRegistryRef.current; - if (!shellRegistry) return; - autoStartedRef.current.add(pane.id); - shellRegistry.ensureShell(pane.id).runCommand(pane.command); - }, []); +function PlaygroundRedirect() { + const navigate = useNavigate(); + const preferred = usePreferredPlayground(); useEffect(() => { - let cancelled = false; - const panes: PaneSpec[] = isPhone - ? [ - { id: PANE_MAIN, command: "tut" }, - { id: PANE_SPLASH, command: "ascii-splash" }, - ] - : [ - { id: PANE_MAIN, command: "tut" }, - { id: PANE_BOXED, command: "changelog" }, - { id: PANE_SPLASH, command: "ascii-splash" }, - ]; - async function loadWall() { - const platform = await import("dormouse-lib/lib/platform"); - const registry = await import("dormouse-lib/lib/terminal-registry"); - const mouseSelection = await import("dormouse-lib/lib/mouse-selection"); - const wall = await import("dormouse-lib/components/Wall"); - const scenarios = await import("dormouse-lib/lib/platform/fake-scenarios"); - const asciiSplash = await import("../lib/ascii-splash-runner"); - await import("dormouse-lib/index.css"); - if (cancelled) return; - - const adapter = platform.initPlatform("fake"); - registry.initAlertStateReceiver(); - adapterRef.current = adapter; - - adapter.setDefaultScenario(scenarios.SCENARIO_SHELL_PROMPT); - // Each runner-owned pane suppresses the default shell-prompt scenario, - // otherwise spawnPty queues a delayed `user@dormouse:~$` write that - // would land in the runner's alt-screen and corrupt its output. - for (const pane of panes) { - adapter.setScenario(pane.id, { name: "none", chunks: [] }); - } - - const tutorialState = new TutorialState(); - stateRef.current = tutorialState; - const detector = new TutDetector(tutorialState, registry, mouseSelection, { - onWatchingDemoPaneChange: (id) => { - alertDemoPaneIdRef.current = id; - }, - }); - detectorRef.current = detector; - - const shellRegistry = new PlaygroundShellRegistry( - adapter, - (terminalId, name, args, onExit) => { - if (name === "tut") { - return new TutRunner({ - adapter, - terminalId, - state: tutorialState, - onExit, - onTriggerBusyDemo: () => { - const paneId = alertDemoPaneIdRef.current ?? PANE_BOXED; - const sessionId = registry.resolveTerminalSessionId(paneId); - busyDemoDisposeRef.current?.(); - busyDemoDisposeRef.current = adapter.pumpActivity( - sessionId, - BUSY_DEMO_DURATION_MS, - BUSY_DEMO_INTERVAL_MS, - ); - }, - onTogglePlaceToPaste: () => setPlaceToPasteOpen((open) => !open), - onOpenGithub: handleOpenGithub, - onOpenPocket: handleOpenPocket, - }); - } - if (name === "ascii-splash" || name === "splash") { - return new asciiSplash.AsciiSplashRunner({ - adapter, - terminalId, - args, - onExit, - }); - } - if (name === "changelog") { - return new ChangelogRunner({ adapter, terminalId, onExit }); - } - return null; - }, - ); - shellRegistryRef.current = shellRegistry; - - for (const pane of panes) shellRegistry.ensureShell(pane.id); - - const paneById = new Map(panes.map((p) => [p.id, p])); - // Subscribe before Wall mounts so the spawn fired by TerminalPane's - // mount effect doesn't race past us. If the pty already exists by - // the time we get here, fire immediately. - spawnUnsubRef.current = adapter.onPtySpawn(({ id }) => { - const pane = paneById.get(id); - if (pane) tryAutoStart(pane); - }); - for (const pane of panes) { - if (adapter.hasPty(pane.id)) tryAutoStart(pane); - } - - setWallModule({ Wall: wall.Wall }); - } - loadWall(); - - return () => { - cancelled = true; - for (const disposable of dockviewDisposablesRef.current) { - disposable.dispose(); - } - dockviewDisposablesRef.current = []; - detectorRef.current?.dispose(); - detectorRef.current = null; - shellRegistryRef.current?.disposeAll(); - shellRegistryRef.current = null; - stateRef.current = null; - autoStartedRef.current.clear(); - alertDemoPaneIdRef.current = null; - spawnUnsubRef.current?.(); - spawnUnsubRef.current = null; - busyDemoDisposeRef.current?.(); - busyDemoDisposeRef.current = null; - }; - }, [handleOpenGithub, isPhone, tryAutoStart]); - - const handleApiReady = useCallback((api: any) => { - const shellRegistry = shellRegistryRef.current; - shellRegistry?.ensureShell(PANE_MAIN); - - const addDisposable = api.onDidAddPanel((panel: { id?: string } | undefined) => { - if (panel?.id) shellRegistryRef.current?.ensureShell(panel.id); - }); - dockviewDisposablesRef.current.push(addDisposable); - - if (isPhone) { - api.addPanel({ - id: PANE_SPLASH, - component: "terminal", - tabComponent: "terminal", - title: "ascii-splash", - position: { referencePanel: PANE_MAIN, direction: "below" }, - }); - } else { - api.addPanel({ - id: PANE_BOXED, - component: "terminal", - tabComponent: "terminal", - title: "changelog", - position: { referencePanel: PANE_MAIN, direction: "right" }, - }); - api.addPanel({ - id: PANE_SPLASH, - component: "terminal", - tabComponent: "terminal", - title: "ascii-splash", - position: { referencePanel: PANE_BOXED, direction: "below" }, - }); - } - - const mainPanel = api.getPanel(PANE_MAIN); - if (mainPanel) { - mainPanel.api.setTitle("tutorial"); - mainPanel.api.setActive(); - } - - detectorRef.current?.attach(api); - }, [isPhone]); - - const handleWallEvent = useCallback((event: WallEvent) => { - detectorRef.current?.handleWallEvent(event); - }, []); + navigate( + { + pathname: preferred === "pocket" + ? POCKET_PLAYGROUND_PATH + : DESKTOP_PLAYGROUND_PATH, + search: window.location.search, + hash: window.location.hash, + }, + { replace: true }, + ); + }, [navigate, preferred]); return ( - <> - - } - /> - -

- {WallModule ? ( - - ) : null} -
- {placeToPasteOpen ? ( - setPlaceToPasteOpen(false)} /> - ) : null} - +
); } diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx new file mode 100644 index 0000000..e0177b9 --- /dev/null +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -0,0 +1,278 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Link } from "react-router-dom"; +import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; +import { PlaceToPaste } from "../components/PlaceToPaste"; +import { POCKET_THEME_ID } from "../components/PocketTerminalExperience"; +import { ThemePicker } from "dormouse-lib/components/ThemePicker"; +import { PlaygroundShellRegistry } from "../lib/playground-shells"; +import { TutorialState } from "../lib/tutorial-state"; +import { TutDetector } from "../lib/tut-detector"; +import { BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, TutRunner } from "../lib/tut-runner"; +import { ChangelogRunner } from "../lib/changelog-runner"; +import { POCKET_PLAYGROUND_PATH, usePreferredPlayground } from "../lib/playground-routing"; + +export { PlaygroundDesktop as Component }; + +const PANE_MAIN = "tut-main"; +const PANE_BOXED = "tut-boxed"; +const PANE_SPLASH = "tut-splash"; +const DESKTOP_PANES: PaneSpec[] = [ + { id: PANE_MAIN, command: "tut" }, + { id: PANE_BOXED, command: "changelog" }, + { id: PANE_SPLASH, command: "ascii-splash" }, +]; + +type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; +type WallEvent = import("dormouse-lib/components/Wall").WallEvent; +type DockviewDisposable = { dispose: () => void }; + +interface PaneSpec { + id: string; + command: string; +} + +function DesktopPlaygroundUnavailable() { + return ( +
+ +
+

+ Desktop playground +

+

+ This screen is too small to run the desktop playground, but it is perfect for trying the{" "} + + Pocket playground + + . +

+

+ Alternatively, widen the window to fit the desktop playground and it will pop into view. +

+
+
+ ); +} + +function PlaygroundDesktopExperience() { + const [WallModule, setWallModule] = useState<{ + Wall: React.ComponentType; + } | null>(null); + const [placeToPasteOpen, setPlaceToPasteOpen] = useState(false); + + const adapterRef = useRef(null); + const shellRegistryRef = useRef(null); + const detectorRef = useRef(null); + const stateRef = useRef(null); + const dockviewDisposablesRef = useRef([]); + const autoStartedRef = useRef>(new Set()); + const spawnUnsubRef = useRef<(() => void) | null>(null); + const busyDemoDisposeRef = useRef<(() => void) | null>(null); + const alertDemoPaneIdRef = useRef(null); + + const handleOpenGithub = useCallback(() => { + window.open( + "https://github.com/diffplug/dormouse", + "_blank", + "noopener,noreferrer", + ); + }, []); + + const handleOpenPocket = useCallback(() => { + window.open(POCKET_PLAYGROUND_PATH, "_blank", "noopener,noreferrer"); + }, []); + + const tryAutoStart = useCallback((pane: PaneSpec) => { + if (autoStartedRef.current.has(pane.id)) return; + const shellRegistry = shellRegistryRef.current; + if (!shellRegistry) return; + autoStartedRef.current.add(pane.id); + shellRegistry.ensureShell(pane.id).runCommand(pane.command); + }, []); + + useEffect(() => { + let cancelled = false; + async function loadWall() { + const platform = await import("dormouse-lib/lib/platform"); + const registry = await import("dormouse-lib/lib/terminal-registry"); + const mouseSelection = await import("dormouse-lib/lib/mouse-selection"); + const wall = await import("dormouse-lib/components/Wall"); + const scenarios = await import("dormouse-lib/lib/platform/fake-scenarios"); + const asciiSplash = await import("../lib/ascii-splash-runner"); + await import("dormouse-lib/index.css"); + if (cancelled) return; + + const adapter = platform.initPlatform("fake"); + registry.initAlertStateReceiver(); + adapterRef.current = adapter; + + adapter.setDefaultScenario(scenarios.SCENARIO_SHELL_PROMPT); + // Each runner-owned pane suppresses the default shell-prompt scenario, + // otherwise spawnPty queues a delayed `user@dormouse:~$` write that + // would land in the runner's alt-screen and corrupt its output. + for (const pane of DESKTOP_PANES) { + adapter.setScenario(pane.id, { name: "none", chunks: [] }); + } + + const tutorialState = new TutorialState(); + stateRef.current = tutorialState; + const detector = new TutDetector(tutorialState, registry, mouseSelection, { + onWatchingDemoPaneChange: (id) => { + alertDemoPaneIdRef.current = id; + }, + }); + detectorRef.current = detector; + + const shellRegistry = new PlaygroundShellRegistry( + adapter, + (terminalId, name, args, onExit) => { + if (name === "tut") { + return new TutRunner({ + adapter, + terminalId, + state: tutorialState, + onExit, + onTriggerBusyDemo: () => { + const paneId = alertDemoPaneIdRef.current ?? PANE_BOXED; + const sessionId = registry.resolveTerminalSessionId(paneId); + busyDemoDisposeRef.current?.(); + busyDemoDisposeRef.current = adapter.pumpActivity( + sessionId, + BUSY_DEMO_DURATION_MS, + BUSY_DEMO_INTERVAL_MS, + ); + }, + onTogglePlaceToPaste: () => setPlaceToPasteOpen((open) => !open), + onOpenGithub: handleOpenGithub, + onOpenPocket: handleOpenPocket, + }); + } + if (name === "ascii-splash" || name === "splash") { + return new asciiSplash.AsciiSplashRunner({ + adapter, + terminalId, + args, + onExit, + }); + } + if (name === "changelog") { + return new ChangelogRunner({ adapter, terminalId, onExit }); + } + return null; + }, + ); + shellRegistryRef.current = shellRegistry; + + for (const pane of DESKTOP_PANES) shellRegistry.ensureShell(pane.id); + + const paneById = new Map(DESKTOP_PANES.map((p) => [p.id, p])); + // Subscribe before Wall mounts so the spawn fired by TerminalPane's + // mount effect doesn't race past us. If the pty already exists by + // the time we get here, fire immediately. + spawnUnsubRef.current = adapter.onPtySpawn(({ id }) => { + const pane = paneById.get(id); + if (pane) tryAutoStart(pane); + }); + for (const pane of DESKTOP_PANES) { + if (adapter.hasPty(pane.id)) tryAutoStart(pane); + } + + setWallModule({ Wall: wall.Wall }); + } + loadWall(); + + return () => { + cancelled = true; + for (const disposable of dockviewDisposablesRef.current) { + disposable.dispose(); + } + dockviewDisposablesRef.current = []; + detectorRef.current?.dispose(); + detectorRef.current = null; + shellRegistryRef.current?.disposeAll(); + shellRegistryRef.current = null; + stateRef.current = null; + autoStartedRef.current.clear(); + alertDemoPaneIdRef.current = null; + spawnUnsubRef.current?.(); + spawnUnsubRef.current = null; + busyDemoDisposeRef.current?.(); + busyDemoDisposeRef.current = null; + }; + }, [handleOpenGithub, handleOpenPocket, tryAutoStart]); + + const handleApiReady = useCallback((api: any) => { + const shellRegistry = shellRegistryRef.current; + shellRegistry?.ensureShell(PANE_MAIN); + + const addDisposable = api.onDidAddPanel((panel: { id?: string } | undefined) => { + if (panel?.id) shellRegistryRef.current?.ensureShell(panel.id); + }); + dockviewDisposablesRef.current.push(addDisposable); + + api.addPanel({ + id: PANE_BOXED, + component: "terminal", + tabComponent: "terminal", + title: "changelog", + position: { referencePanel: PANE_MAIN, direction: "right" }, + }); + api.addPanel({ + id: PANE_SPLASH, + component: "terminal", + tabComponent: "terminal", + title: "ascii-splash", + position: { referencePanel: PANE_BOXED, direction: "below" }, + }); + + const mainPanel = api.getPanel(PANE_MAIN); + if (mainPanel) { + mainPanel.api.setTitle("tutorial"); + mainPanel.api.setActive(); + } + + detectorRef.current?.attach(api); + }, []); + + const handleWallEvent = useCallback((event: WallEvent) => { + detectorRef.current?.handleWallEvent(event); + }, []); + + return ( + <> + + } + /> + +
+ {WallModule ? ( + + ) : null} +
+ {placeToPasteOpen ? ( + setPlaceToPasteOpen(false)} /> + ) : null} + + ); +} + +function PlaygroundDesktop() { + const preferred = usePreferredPlayground(); + if (preferred === "pocket") return ; + return ; +} diff --git a/website/src/pages/Pocket.tsx b/website/src/pages/Pocket.tsx index 41ca3a0..34ab397 100644 --- a/website/src/pages/Pocket.tsx +++ b/website/src/pages/Pocket.tsx @@ -1,319 +1,24 @@ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { ShareIcon } from "@phosphor-icons/react"; -import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; -import { NotifySignupForm } from "../components/NotifySignupForm"; -import { MobileTerminalUi, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode } from "dormouse-lib/components/MobileTerminalUi"; -import { MobileWall, useMobileWallSessionItems, type MobileWallSession } from "dormouse-lib/components/MobileWall"; -import { ThemePicker } from "dormouse-lib/components/ThemePicker"; -import { restoreActiveTheme } from "dormouse-lib/lib/themes"; -import { - getMouseSelectionSnapshot, - setOverride as setMouseOverride, - subscribeToMouseSelection, -} from "dormouse-lib/lib/mouse-selection"; -import { PlaygroundShellRegistry } from "../lib/playground-shells"; -import { TutorialState } from "../lib/tutorial-state"; -import { BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, TutRunner } from "../lib/tut-runner"; -import { ChangelogRunner } from "../lib/changelog-runner"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { POCKET_PLAYGROUND_PATH } from "../lib/playground-routing"; export { Pocket as Component }; -type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; - -const POCKET_PANE = "pocket-ascii-splash"; -const POCKET_THEME_ID = "vscode.theme-kimbie-dark.kimbie-dark"; -const POCKET_SESSIONS: MobileWallSession[] = [{ id: POCKET_PANE, title: "ascii-splash" }]; - -function useIsMobileViewport() { - const [isMobile, setIsMobile] = useState(false); +function Pocket() { + const navigate = useNavigate(); useEffect(() => { - const media = window.matchMedia("(max-width: 767px)"); - const update = () => setIsMobile(media.matches); - update(); - media.addEventListener("change", update); - return () => media.removeEventListener("change", update); - }, []); - - return isMobile; -} - -function usePocketTheme() { - const restoredRef = useRef(false); - if (!restoredRef.current) { - restoreActiveTheme(POCKET_THEME_ID); - restoredRef.current = true; - } -} - -function PocketTerminalExperience({ - interactive, - fillViewport = false, -}: { - interactive: boolean; - fillViewport?: boolean; -}) { - usePocketTheme(); - const [terminalReady, setTerminalReady] = useState(false); - const adapterRef = useRef(null); - const shellRegistryRef = useRef(null); - const autoStartedRef = useRef>(new Set()); - const spawnUnsubRef = useRef<(() => void) | null>(null); - const busyDemoDisposeRef = useRef<(() => void) | null>(null); - const [activePaneId, setActivePaneId] = useState(POCKET_PANE); - const [touchMode, setTouchMode] = useState("gestures"); - const [keyboardMode, setKeyboardMode] = useState("type"); - const sessionItems = useMobileWallSessionItems(POCKET_SESSIONS, activePaneId); - const mouseStates = useSyncExternalStore( - subscribeToMouseSelection, - getMouseSelectionSnapshot, - getMouseSelectionSnapshot, - ); - const activeMouseState = mouseStates.get(activePaneId); - const cursorTouchAvailable = activeMouseState?.mouseReporting !== undefined - && activeMouseState.mouseReporting !== "none"; - - const handleOpenGithub = useCallback(() => { - window.open( - "https://github.com/diffplug/dormouse", - "_blank", - "noopener,noreferrer", + navigate( + { + pathname: POCKET_PLAYGROUND_PATH, + search: window.location.search, + hash: window.location.hash, + }, + { replace: true }, ); - }, []); - - const handleOpenPocket = useCallback(() => { - window.open("https://dormouse.sh/pocket", "_blank", "noopener,noreferrer"); - }, []); - - const tryAutoStart = useCallback((id: string) => { - if (id !== POCKET_PANE) return; - if (autoStartedRef.current.has(id)) return; - const shellRegistry = shellRegistryRef.current; - if (!shellRegistry) return; - autoStartedRef.current.add(id); - shellRegistry.ensureShell(id).runCommand("ascii-splash"); - }, []); - - useEffect(() => { - let cancelled = false; - - async function loadWall() { - const platform = await import("dormouse-lib/lib/platform"); - const registry = await import("dormouse-lib/lib/terminal-registry"); - const scenarios = await import("dormouse-lib/lib/platform/fake-scenarios"); - const asciiSplash = await import("../lib/ascii-splash-runner"); - await import("dormouse-lib/index.css"); - if (cancelled) return; - - const adapter = platform.initPlatform("fake"); - registry.disposeAllSessions(); - adapter.reset(); - registry.initAlertStateReceiver(); - adapterRef.current = adapter; - adapter.setDefaultScenario(scenarios.SCENARIO_SHELL_PROMPT); - adapter.setScenario(POCKET_PANE, { name: "none", chunks: [] }); - - const tutorialState = new TutorialState(); - const shellRegistry = new PlaygroundShellRegistry( - adapter, - (terminalId, name, args, onExit) => { - if (name === "tut") { - return new TutRunner({ - adapter, - terminalId, - state: tutorialState, - onExit, - onTriggerBusyDemo: () => { - busyDemoDisposeRef.current?.(); - busyDemoDisposeRef.current = adapter.pumpActivity( - terminalId, - BUSY_DEMO_DURATION_MS, - BUSY_DEMO_INTERVAL_MS, - ); - }, - onTogglePlaceToPaste: () => {}, - onOpenGithub: handleOpenGithub, - onOpenPocket: handleOpenPocket, - }); - } - if (name === "ascii-splash" || name === "splash") { - return new asciiSplash.AsciiSplashRunner({ - adapter, - terminalId, - args, - onExit, - }); - } - if (name === "changelog") { - return new ChangelogRunner({ adapter, terminalId, onExit }); - } - return null; - }, - ); - shellRegistryRef.current = shellRegistry; - shellRegistry.ensureShell(POCKET_PANE); - - spawnUnsubRef.current = adapter.onPtySpawn(({ id }) => { - shellRegistry.ensureShell(id); - tryAutoStart(id); - }); - if (adapter.hasPty(POCKET_PANE)) tryAutoStart(POCKET_PANE); - - setTerminalReady(true); - } - - loadWall(); - - return () => { - cancelled = true; - spawnUnsubRef.current?.(); - spawnUnsubRef.current = null; - busyDemoDisposeRef.current?.(); - busyDemoDisposeRef.current = null; - shellRegistryRef.current?.disposeAll(); - shellRegistryRef.current = null; - autoStartedRef.current.clear(); - adapterRef.current = null; - }; - }, [handleOpenGithub, tryAutoStart]); - - useEffect(() => { - const reporting = activeMouseState?.mouseReporting ?? "none"; - if (touchMode === "selection" && reporting !== "none") { - setMouseOverride(activePaneId, "permanent"); - } else { - setMouseOverride(activePaneId, "off"); - } - }, [activeMouseState?.mouseReporting, activePaneId, touchMode]); - - return ( - setKeyboardMode("sessions")} - /> - ) : null - } - interactive={interactive} - fillViewport={fillViewport} - activeTouchMode={touchMode} - onTouchModeChange={setTouchMode} - activeKeyboardMode={keyboardMode} - onKeyboardModeChange={setKeyboardMode} - cursorTouchAvailable={cursorTouchAvailable} - sessions={sessionItems} - onSessionSelect={setActivePaneId} - onSendInput={(data) => adapterRef.current?.writePty(activePaneId, data)} - onPaste={async () => { - const { doPaste } = await import("dormouse-lib/lib/clipboard"); - await doPaste(activePaneId); - }} - /> - ); -} - -function MobilePocketPage() { - return ( -
- -
- -
-
- ); -} - -function ShareUrlButton() { - const [copied, setCopied] = useState(false); - - async function handleShare() { - const url = window.location.href; - if (navigator.share) { - try { - await navigator.share({ url, title: "Dormouse Pocket" }); - return; - } catch (err) { - if ((err as DOMException)?.name === "AbortError") return; - } - } - try { - await navigator.clipboard.writeText(url); - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); - } catch { - window.prompt("Copy this URL to your phone:", url); - } - } + }, [navigate]); return ( - +
); } - -function DesktopPocketPage() { - return ( -
- } - /> -
-
-

- Walk away. Keep going. -

-

- Come back right now on mobile{" "} - {" "} - to try it out! (WIP) -

-

- Tether a terminal session to your phone over WebRTC and take a stroll — Dormouse - buzzes your phone when something needs attention. A hosted auto-pairing service comes - later, so you can close the laptop and walk away, no setup dance. -

-

- Open source and free to self-host, or pay a small monthly fee for our hosted version. Early adopters get a launch discount. -

- -
- -
-
-
-
- -
-
-
-
-
- ); -} - -function Pocket() { - const isMobile = useIsMobileViewport(); - - useEffect(() => { - const className = isMobile ? "pocket-terminal-body" : "pocket-marketing-body"; - document.body.classList.add(className); - return () => document.body.classList.remove(className); - }, [isMobile]); - - return isMobile ? : ; -} diff --git a/website/src/pages/PocketPlayground.tsx b/website/src/pages/PocketPlayground.tsx new file mode 100644 index 0000000..45d950c --- /dev/null +++ b/website/src/pages/PocketPlayground.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; +import { + POCKET_THEME_ID, + PocketTerminalExperience, +} from "../components/PocketTerminalExperience"; +import { NotifySignupForm } from "../components/NotifySignupForm"; +import { ShareUrlButton } from "../components/ShareUrlButton"; +import { ThemePicker } from "dormouse-lib/components/ThemePicker"; +import { POCKET_PLAYGROUND_PATH, usePreferredPlayground } from "../lib/playground-routing"; + +export { PocketPlayground as Component }; + +function MobilePocketPlaygroundPage() { + return ( +
+ +
+ +
+
+ ); +} + +function DesktopPocketPlaygroundPage() { + return ( +
+ } + /> +
+
+

+ Walk away. Keep going. +

+

+ Come back on mobile{" "} + {" "} + to try it out! (WIP) +

+

+ Tether a terminal session to your phone over WebRTC and take a stroll. Dormouse + buzzes your phone when something needs attention. A hosted auto-pairing service comes + later, so you can close the laptop and walk away, no setup dance. +

+

+ Open source and free to self-host, or pay a small monthly fee for our hosted version. + Early adopters get a launch discount. +

+ +
+ +
+
+
+
+ +
+
+
+
+
+ ); +} + +function PocketPlayground() { + const preferred = usePreferredPlayground(); + + useEffect(() => { + const className = preferred === "pocket" ? "pocket-terminal-body" : "pocket-marketing-body"; + document.body.classList.add(className); + return () => document.body.classList.remove(className); + }, [preferred]); + + return preferred === "pocket" ? : ; +}