From 846a3c3696e5d72c9259230aab0d38ab76392bff Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 22 May 2026 09:38:40 -0700 Subject: [PATCH 1/4] Split playground routes for desktop and pocket --- docs/specs/mobile-ui.md | 31 +- docs/specs/theme.md | 11 +- docs/specs/tutorial.md | 31 +- website/src/App.tsx | 8 + .../components/PocketTerminalExperience.tsx | 198 ++++++++++++ website/src/components/ShareUrlButton.tsx | 46 +++ website/src/index.css | 7 +- website/src/lib/playground-routing.ts | 27 ++ website/src/lib/tut-runner.ts | 2 +- website/src/pages/Playground.tsx | 283 ++--------------- website/src/pages/PlaygroundDesktop.tsx | 274 +++++++++++++++++ website/src/pages/Pocket.tsx | 290 +----------------- website/src/pages/PocketPlayground.tsx | 81 +++++ 13 files changed, 719 insertions(+), 570 deletions(-) create mode 100644 website/src/components/PocketTerminalExperience.tsx create mode 100644 website/src/components/ShareUrlButton.tsx create mode 100644 website/src/lib/playground-routing.ts create mode 100644 website/src/pages/PlaygroundDesktop.tsx create mode 100644 website/src/pages/PocketPlayground.tsx diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 0c5a715..24f9ef1 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,16 @@ 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 is the product/feature page and must +not be used as the fake playground URL. -`/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 +79,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 +114,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 +379,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..6fc7899 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -152,11 +152,12 @@ 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, `/pocket`, 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. 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..217b4ae 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 uses `(max-width: 767px), (pointer: coarse)` to replace the history entry with either `/playground/desktop` or `/playground/pocket`. +- `/playground/desktop` hosts the desktop tiling tutorial. On small or coarse-pointer screens 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 a marketing/share page that prompts the user to send `/playground/pocket` to a phone and links to the `/pocket` product page. +- `/pocket` is the Pocket product/feature page. It is not the real tethering environment and is not the playground URL; the future real tethering surface should stay separate from the playground URL. + +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`, `/playground/pocket`, and `/pocket`, labeled `Theme:`. On `/playground/desktop` it is inside the theme-aware `SiteHeader`; on `/playground/pocket` mobile it floats over the terminal; on the Pocket marketing pages it uses the standalone appbar variant. 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..2a2bba3 --- /dev/null +++ b/website/src/components/PocketTerminalExperience.tsx @@ -0,0 +1,198 @@ +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"; + +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", "_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..5cc3db8 --- /dev/null +++ b/website/src/components/ShareUrlButton.tsx @@ -0,0 +1,46 @@ +import { 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); + + async function handleShare() { + const url = path + ? new URL(path, 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); + window.setTimeout(() => 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..ddf9c30 --- /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: 767px), (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(null); + + 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/Playground.tsx b/website/src/pages/Playground.tsx index 54439f2..b0118d6 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -1,267 +1,32 @@ -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); - }, []); + if (preferred === null) return; + 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..3282e7f --- /dev/null +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -0,0 +1,274 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import SiteHeader, { STATIC_PAGE_HEADER_STYLE } 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 { 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 + + . +

+
+
+ ); +} + +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", "_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 === null) return null; + if (preferred === "pocket") return ; + return ; +} diff --git a/website/src/pages/Pocket.tsx b/website/src/pages/Pocket.tsx index 41ca3a0..e4e4d2e 100644 --- a/website/src/pages/Pocket.tsx +++ b/website/src/pages/Pocket.tsx @@ -1,268 +1,22 @@ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { ShareIcon } from "@phosphor-icons/react"; +import { useEffect } from "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"; + POCKET_THEME_ID, + PocketTerminalExperience, +} from "../components/PocketTerminalExperience"; +import { ShareUrlButton } from "../components/ShareUrlButton"; +import { ThemePicker } from "dormouse-lib/components/ThemePicker"; +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() { useEffect(() => { - const media = window.matchMedia("(max-width: 767px)"); - const update = () => setIsMobile(media.matches); - update(); - media.addEventListener("change", update); - return () => media.removeEventListener("change", update); + document.body.classList.add("pocket-marketing-body"); + return () => document.body.classList.remove("pocket-marketing-body"); }, []); - 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", - ); - }, []); - - 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); - } - } - - return ( - - ); -} - -function DesktopPocketPage() { return (
-

+

Walk away. Keep going.

- Come back right now on mobile{" "} - {" "} - to try it out! (WIP) + Pocket tethering is coming soon. Try the mobile playground now{" "} + .

- Tether a terminal session to your phone over WebRTC and take a stroll — Dormouse + 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. + The Pocket playground is a tutorial for the mobile controls, not the real tethering + environment. Open source self-hosting and a small hosted plan are planned for launch.

@@ -305,15 +59,3 @@ function DesktopPocketPage() {
); } - -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..19176a6 --- /dev/null +++ b/website/src/pages/PocketPlayground.tsx @@ -0,0 +1,81 @@ +import { useEffect } from "react"; +import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; +import { + POCKET_THEME_ID, + PocketTerminalExperience, +} from "../components/PocketTerminalExperience"; +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 ( +
+ } + /> +
+
+

+ Pocket playground +

+

+ This playground is built for phone-sized touch controls. Share it to your phone{" "} + . +

+

+ It teaches the mobile interface for Dormouse Pocket. It is not the real tethering + environment; that future product surface will stay separate from the playground URL. +

+

+ Want the product details instead?{" "} + + Read about Dormouse Pocket + + . +

+
+ +
+
+
+
+ +
+
+
+
+
+ ); +} + +function PocketPlayground() { + const preferred = usePreferredPlayground(); + + useEffect(() => { + if (preferred === null) return; + const className = preferred === "pocket" ? "pocket-terminal-body" : "pocket-marketing-body"; + document.body.classList.add(className); + return () => document.body.classList.remove(className); + }, [preferred]); + + if (preferred === null) return null; + return preferred === "pocket" ? : ; +} From f8d841398d7503d8e62ff895986a0c8107a40576 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 22 May 2026 13:58:35 -0700 Subject: [PATCH 2/4] Tune playground pocket routing --- docs/specs/mobile-ui.md | 5 +- docs/specs/theme.md | 11 +++-- docs/specs/tutorial.md | 10 ++-- website/src/lib/playground-routing.ts | 2 +- website/src/pages/PlaygroundDesktop.tsx | 7 ++- website/src/pages/Pocket.tsx | 63 +++++-------------------- website/src/pages/PocketPlayground.tsx | 23 ++++----- 7 files changed, 45 insertions(+), 76 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 24f9ef1..7dd4b58 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -16,8 +16,9 @@ need remote sessions, SSH, user accounts, or production infrastructure. 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 is the product/feature page and must -not be used as the fake playground URL. +interactive terminal. The `/pocket` route temporarily redirects to +`/playground/pocket`; this is a launch-state redirect, not the future real +tethering environment. `/playground/pocket` uses the same fake playground terminal stack as `/playground/desktop`: `PlaygroundShellRegistry` attaches a `TutorialShell` to diff --git a/docs/specs/theme.md b/docs/specs/theme.md index 6fc7899..037e353 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -152,11 +152,12 @@ 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, `/pocket`, 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. VSCode opens it through 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 217b4ae..97dc923 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -2,10 +2,10 @@ The website playground has canonical device-specific routes: -- `/playground` is a client-side dispatcher. It uses `(max-width: 767px), (pointer: coarse)` to replace the history entry with either `/playground/desktop` or `/playground/pocket`. -- `/playground/desktop` hosts the desktop tiling tutorial. On small or coarse-pointer screens 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 a marketing/share page that prompts the user to send `/playground/pocket` to a phone and links to the `/pocket` product page. -- `/pocket` is the Pocket product/feature page. It is not the real tethering environment and is not the playground URL; the future real tethering surface should stay separate from the playground URL. +- `/playground` is a client-side dispatcher. It uses `(max-width: 249px), (pointer: coarse)` to replace the history entry with either `/playground/desktop` or `/playground/pocket`. A coarse pointer always prefers Pocket; non-coarse pointers can run the desktop playground at 250px and wider. +- `/playground/desktop` hosts the desktop tiling tutorial. On screens narrower than 250px or on coarse-pointer devices 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. @@ -120,7 +120,7 @@ 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 on `/playground/desktop`, `/playground/pocket`, and `/pocket`, labeled `Theme:`. On `/playground/desktop` it is inside the theme-aware `SiteHeader`; on `/playground/pocket` mobile it floats over the terminal; on the Pocket marketing pages it uses the standalone appbar variant. 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`) diff --git a/website/src/lib/playground-routing.ts b/website/src/lib/playground-routing.ts index ddf9c30..c61de4a 100644 --- a/website/src/lib/playground-routing.ts +++ b/website/src/lib/playground-routing.ts @@ -5,7 +5,7 @@ export const POCKET_PLAYGROUND_PATH = "/playground/pocket"; export type PreferredPlayground = "desktop" | "pocket"; -const POCKET_PLAYGROUND_QUERY = "(max-width: 767px), (pointer: coarse)"; +const POCKET_PLAYGROUND_QUERY = "(max-width: 700px), (pointer: coarse)"; function getPreferredPlayground(): PreferredPlayground { if (typeof window === "undefined") return "desktop"; diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index 3282e7f..7306a9e 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -37,8 +37,8 @@ function DesktopPlaygroundUnavailable() {

Desktop playground

-

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

+ This screen is too small to run the desktop playground. Make it at least 250px wide, but it is perfect for trying the{" "} .

+

+ Alternatively, make the window at least 250px wide and the playground will pop into view. +

); diff --git a/website/src/pages/Pocket.tsx b/website/src/pages/Pocket.tsx index e4e4d2e..34ab397 100644 --- a/website/src/pages/Pocket.tsx +++ b/website/src/pages/Pocket.tsx @@ -1,61 +1,24 @@ import { useEffect } from "react"; -import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; -import { NotifySignupForm } from "../components/NotifySignupForm"; -import { - POCKET_THEME_ID, - PocketTerminalExperience, -} from "../components/PocketTerminalExperience"; -import { ShareUrlButton } from "../components/ShareUrlButton"; -import { ThemePicker } from "dormouse-lib/components/ThemePicker"; +import { useNavigate } from "react-router-dom"; import { POCKET_PLAYGROUND_PATH } from "../lib/playground-routing"; export { Pocket as Component }; function Pocket() { + const navigate = useNavigate(); + useEffect(() => { - document.body.classList.add("pocket-marketing-body"); - return () => document.body.classList.remove("pocket-marketing-body"); - }, []); + navigate( + { + pathname: POCKET_PLAYGROUND_PATH, + search: window.location.search, + hash: window.location.hash, + }, + { replace: true }, + ); + }, [navigate]); return ( -
- } - /> -
-
-

- Walk away. Keep going. -

-

- Pocket tethering is coming soon. Try the mobile playground now{" "} - . -

-

- 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. -

-

- The Pocket playground is a tutorial for the mobile controls, not the real tethering - environment. Open source self-hosting and a small hosted plan are planned for launch. -

- -
- -
-
-
-
- -
-
-
-
-
+
); } diff --git a/website/src/pages/PocketPlayground.tsx b/website/src/pages/PocketPlayground.tsx index 19176a6..b631faa 100644 --- a/website/src/pages/PocketPlayground.tsx +++ b/website/src/pages/PocketPlayground.tsx @@ -4,6 +4,7 @@ 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"; @@ -32,23 +33,23 @@ function DesktopPocketPlaygroundPage() {

- Pocket playground + Walk away. Keep going.

- This playground is built for phone-sized touch controls. Share it to your phone{" "} - . + Come back on mobile{" "} + {" "} + to try it out! (WIP)

- It teaches the mobile interface for Dormouse Pocket. It is not the real tethering - environment; that future product surface will stay separate from the playground URL. + 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.

-

- Want the product details instead?{" "} - - Read about Dormouse Pocket - - . +

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

+
From 58c0878473fd0eb7633bc7a0f4ca397fc66beaee Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 22 May 2026 14:14:38 -0700 Subject: [PATCH 3/4] Fix playground-split regressions found in code review - usePreferredPlayground: initialize state synchronously so SSG bakes the desktop variant into /pocket and /playground/pocket HTML; pre-PR Pocket used useState(false) and shipped marketing copy to crawlers. - Open POCKET_PLAYGROUND_PATH directly from Flappy Term [p] instead of /pocket, avoiding the redirect chain in the new tab. - Update Home.tsx 'Coming next: Dormouse Pocket' link to skip /pocket. - Restore Playground nav link on the desktop Pocket marketing page by passing activePath="/pocket" (SiteHeader hides /playground when activePath==="/playground"). - Restore ShareUrlButton hover:scale-120 and gap-1 styling that the extraction had silently changed; preserve current location's query and hash when sharing a fixed path; clear the copied-flag timer on unmount and on rapid re-trigger. - Use POCKET_THEME_ID constant (now exported) instead of the literal Kimbie ID in PlaygroundDesktop. - Use react-router Link instead of a plain in DesktopPlaygroundUnavailable and drop the misleading '250px' copy (the actual breakpoint is 700px). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/PocketTerminalExperience.tsx | 3 ++- website/src/components/ShareUrlButton.tsx | 23 +++++++++++++++---- website/src/lib/playground-routing.ts | 2 +- website/src/pages/Home.tsx | 2 +- website/src/pages/Playground.tsx | 1 - website/src/pages/PlaygroundDesktop.tsx | 17 +++++++------- website/src/pages/PocketPlayground.tsx | 4 +--- 7 files changed, 33 insertions(+), 19 deletions(-) diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index 2a2bba3..48acb1e 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -11,6 +11,7 @@ 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"; @@ -63,7 +64,7 @@ export function PocketTerminalExperience({ }, []); const handleOpenPocket = useCallback(() => { - window.open("/pocket", "_blank", "noopener,noreferrer"); + window.open(POCKET_PLAYGROUND_PATH, "_blank", "noopener,noreferrer"); }, []); const tryAutoStart = useCallback((id: string) => { diff --git a/website/src/components/ShareUrlButton.tsx b/website/src/components/ShareUrlButton.tsx index 5cc3db8..e13d5f6 100644 --- a/website/src/components/ShareUrlButton.tsx +++ b/website/src/components/ShareUrlButton.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ShareIcon } from "@phosphor-icons/react"; interface ShareUrlButtonProps { @@ -9,10 +9,19 @@ interface ShareUrlButtonProps { 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.origin).href + ? new URL(`${path}${window.location.search}${window.location.hash}`, window.location.origin).href : window.location.href; if (navigator.share) { try { @@ -25,7 +34,13 @@ export function ShareUrlButton({ path, title, children }: ShareUrlButtonProps) { try { await navigator.clipboard.writeText(url); setCopied(true); - window.setTimeout(() => setCopied(false), 2000); + 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); } @@ -36,7 +51,7 @@ export function ShareUrlButton({ path, title, children }: ShareUrlButtonProps) { type="button" onClick={handleShare} aria-label={`Share ${title}`} - className="inline-flex items-center gap-1.5 align-[-0.2em] rounded text-[var(--color-text)]/90 transition duration-150 hover:scale-105 hover:text-[var(--color-text)]" + className="inline-flex items-center gap-1 align-[-0.2em] rounded text-[var(--color-text)]/90 transition duration-150 hover:scale-120 hover:text-[var(--color-text)]" > {children} diff --git a/website/src/lib/playground-routing.ts b/website/src/lib/playground-routing.ts index c61de4a..7bab740 100644 --- a/website/src/lib/playground-routing.ts +++ b/website/src/lib/playground-routing.ts @@ -13,7 +13,7 @@ function getPreferredPlayground(): PreferredPlayground { } export function usePreferredPlayground() { - const [preferred, setPreferred] = useState(null); + const [preferred, setPreferred] = useState(getPreferredPlayground); useEffect(() => { const media = window.matchMedia(POCKET_PLAYGROUND_QUERY); 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 b0118d6..5ba1850 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -13,7 +13,6 @@ function PlaygroundRedirect() { const preferred = usePreferredPlayground(); useEffect(() => { - if (preferred === null) return; navigate( { pathname: preferred === "pocket" diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index 7306a9e..e0177b9 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -1,6 +1,8 @@ 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"; @@ -38,17 +40,17 @@ function DesktopPlaygroundUnavailable() { Desktop playground

- This screen is too small to run the desktop playground. Make it at least 250px wide, but it is perfect for trying the{" "} - Pocket playground - + .

- Alternatively, make the window at least 250px wide and the playground will pop into view. + Alternatively, widen the window to fit the desktop playground and it will pop into view.

@@ -80,7 +82,7 @@ function PlaygroundDesktopExperience() { }, []); const handleOpenPocket = useCallback(() => { - window.open("/pocket", "_blank", "noopener,noreferrer"); + window.open(POCKET_PLAYGROUND_PATH, "_blank", "noopener,noreferrer"); }, []); const tryAutoStart = useCallback((pane: PaneSpec) => { @@ -247,7 +249,7 @@ function PlaygroundDesktopExperience() { controls={ } /> @@ -271,7 +273,6 @@ function PlaygroundDesktopExperience() { function PlaygroundDesktop() { const preferred = usePreferredPlayground(); - if (preferred === null) return null; if (preferred === "pocket") return ; return ; } diff --git a/website/src/pages/PocketPlayground.tsx b/website/src/pages/PocketPlayground.tsx index b631faa..45d950c 100644 --- a/website/src/pages/PocketPlayground.tsx +++ b/website/src/pages/PocketPlayground.tsx @@ -26,7 +26,7 @@ function DesktopPocketPlaygroundPage() { return (
} /> @@ -71,12 +71,10 @@ function PocketPlayground() { const preferred = usePreferredPlayground(); useEffect(() => { - if (preferred === null) return; const className = preferred === "pocket" ? "pocket-terminal-body" : "pocket-marketing-body"; document.body.classList.add(className); return () => document.body.classList.remove(className); }, [preferred]); - if (preferred === null) return null; return preferred === "pocket" ? : ; } From 950a93f643001c170cf366d2c1e76d97f27311d2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 22 May 2026 14:37:52 -0700 Subject: [PATCH 4/4] Remove hardcoded breakpoint from tutorial spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec should not pin the exact media query — code is the source of truth. Describes the rule (coarse pointer or narrow viewport → Pocket) and points at website/src/lib/playground-routing.ts for the value. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/tutorial.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 97dc923..b46391c 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -2,8 +2,8 @@ The website playground has canonical device-specific routes: -- `/playground` is a client-side dispatcher. It uses `(max-width: 249px), (pointer: coarse)` to replace the history entry with either `/playground/desktop` or `/playground/pocket`. A coarse pointer always prefers Pocket; non-coarse pointers can run the desktop playground at 250px and wider. -- `/playground/desktop` hosts the desktop tiling tutorial. On screens narrower than 250px or on coarse-pointer devices 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` 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.