From 9e47c495c13ee14b53122aadcafb8ebdc9444e94 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 19 May 2026 00:56:33 -0700 Subject: [PATCH 1/7] Add GitHub star prompt to tutorial --- docs/specs/tutorial.md | 7 +++- .../lib/__snapshots__/tut-runner.test.ts.snap | 3 +- website/src/lib/tut-runner.test.ts | 32 ++++++++++++++- website/src/lib/tut-runner.ts | 39 +++++++++++++++++-- website/src/lib/tutorial-state.ts | 34 +++++++++++++++- website/src/pages/Playground.tsx | 11 +++++- website/src/pages/Pocket.tsx | 11 +++++- 7 files changed, 124 insertions(+), 13 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index fa1e88e..c0e2155 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -8,7 +8,7 @@ Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `websi - **`tut-runner.ts`** (`TutRunner`) — alt-screen TUI. Subscribes to `TutorialState` and re-renders whenever progress changes. Routes input bytes via `FakePtyAdapter.writePty(id, …)`. - **`tut-detector.ts`** (`TutDetector`) — wires app events to `TutorialState.markComplete(id)`. Subscribes to `DockviewApi.onDidActivePanelChange`, the `WallEvent` stream, the `subscribeToActivity` store from `dormouse-lib/lib/terminal-registry`, and the `subscribeToMouseSelection` store from `dormouse-lib/lib/mouse-selection`. -- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `dormouse-tut-v3` localStorage key. +- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `dormouse-tut-v3` localStorage key. The top-level GitHub star prompt persists separately under `dormouse-tut-star-v1`. - **`tut-items.ts`** — section + item definitions (titles, hints) shared by runner and detector. Item ids are stable; they are the localStorage key suffixes. ## Layout @@ -29,7 +29,9 @@ Every playground pane gets a `TutorialShell` input handler through `PlaygroundSh ## Tutorial Sections -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. Inside a section, items render as one of: +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 top-level menu also includes `Starred on GitHub`, which participates in the overall progress total but not in any section count, 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")`. + +Inside a section, items render as one of: - `✓` (green) — complete - `●` (yellow active marker) — first incomplete item, with hint text shown below. This marker is intentionally static so runner re-renders do not feed the activity monitor. @@ -101,6 +103,7 @@ While the Copy paste section is open, pressing `p` toggles the **Place To Paste* ## Storage - Completion: `localStorage["dormouse-tut-v3"] = JSON.stringify([...completedItemIds])`. Removed on `TutorialState.reset()`. Unknown ids in a stored payload are filtered out on load, so renaming an id is a one-way reset for that item. +- GitHub star prompt: `localStorage["dormouse-tut-star-v1"] = "true"` after the user selects `Starred on GitHub`. Removed on `TutorialState.reset()`. - Legacy keys `dormouse-tutorial-step-N` and `dormouse-tut-v2-*` from previous designs are not read; new playground sessions get a fresh start. ## Theme Picker diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 12530a1..e97417d 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -79,11 +79,12 @@ exports[`TutRunner snapshots > renders Keyboard navigation with all items incomp exports[`TutRunner snapshots > renders the top-level menu 1`] = ` " Dormouse Playground Tutorial - 0/17 complete · Esc/q to exit · Enter to open · ↑↓ to navigate + 0/18 complete · Esc/q to exit · Enter to open · ↑↓ to navigate ❯ Keyboard navigation [0/7 complete] Alert and TODO [0/6 complete] Copy paste [0/4 complete] + Starred on GitHub [not yet] Reset progress diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 7f58353..7e82b74 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; import { SECTIONS, type ItemId } from "./tut-items"; import { TutRunner } from "./tut-runner"; @@ -6,7 +6,10 @@ import { TutorialState } from "./tutorial-state"; const FRAME_RESET = "\x1b[H\x1b[2J"; -function mountRunner(completedIds: ItemId[] = []) { +function mountRunner( + completedIds: ItemId[] = [], + options: { onOpenGithub?: () => void } = {}, +) { const adapter = new FakePtyAdapter(); const id = "test-pane"; adapter.spawnPty(id); @@ -25,6 +28,7 @@ function mountRunner(completedIds: ItemId[] = []) { onExit: () => { exitCount += 1; }, + onOpenGithub: options.onOpenGithub, }); adapter.setInputHandler(id, (data) => runner.handleInput(data)); runner.start(); @@ -90,4 +94,28 @@ describe("TutRunner snapshots", () => { expect(exitCount()).toBe(1); dispose(); }); + + it("opens GitHub and resolves the star prompt from the menu", () => { + const onOpenGithub = vi.fn(); + const { state, sendKeys, lastFrame, dispose } = mountRunner([], { onOpenGithub }); + + sendKeys("\x1b[B\x1b[B\x1b[B\r"); + + expect(onOpenGithub).toHaveBeenCalledTimes(1); + expect(state.isStarPromptResolved()).toBe(true); + expect(lastFrame()).toContain("[thanks ⭐]"); + expect(lastFrame()).not.toContain("star the repo"); + dispose(); + }); + + it("clears the star prompt when reset progress is confirmed", () => { + const { state, sendKeys, dispose } = mountRunner(["kb-mode"]); + state.resolveStarPrompt(); + + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\rreset\r"); + + expect(state.isComplete("kb-mode")).toBe(false); + expect(state.isStarPromptResolved()).toBe(false); + dispose(); + }); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index aef9e24..b24183f 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -53,6 +53,7 @@ const SPINNER_INTERVAL_MS = 100; * silence. A static glyph keeps the pane quiet between user actions. */ const ACTIVE_ITEM_GLYPH = "●"; +const STAR_PROMPT_TITLE = "Starred on GitHub"; interface TutRunnerOptions { adapter: FakePtyAdapter; @@ -63,6 +64,8 @@ interface TutRunnerOptions { onTriggerBusyDemo?: () => void; /** Called when the user presses `p` inside the Copy paste section. */ onTogglePlaceToPaste?: () => void; + /** Called when the user presses `Enter` on the GitHub star prompt. */ + onOpenGithub?: () => void; } type Screen = "menu" | "section" | "reset"; @@ -76,6 +79,7 @@ export class TutRunner implements InteractiveProgram { private onExit: () => void; private onTriggerBusyDemo?: () => void; private onTogglePlaceToPaste?: () => void; + private onOpenGithub?: () => void; private screen: Screen = "menu"; private menuIndex = 0; @@ -96,6 +100,7 @@ export class TutRunner implements InteractiveProgram { this.onExit = options.onExit; this.onTriggerBusyDemo = options.onTriggerBusyDemo; this.onTogglePlaceToPaste = options.onTogglePlaceToPaste; + this.onOpenGithub = options.onOpenGithub; } start(): void { @@ -205,7 +210,15 @@ export class TutRunner implements InteractiveProgram { // --- Input --- private menuLength(): number { - // SECTIONS + the trailing "Reset progress" entry + // SECTIONS + the GitHub star prompt + the trailing "Reset progress" entry + return SECTIONS.length + 2; + } + + private starPromptIndex(): number { + return SECTIONS.length; + } + + private resetIndex(): number { return SECTIONS.length + 1; } @@ -224,13 +237,19 @@ export class TutRunner implements InteractiveProgram { private handleEnter(): void { if (this.screen === "menu") { - if (this.menuIndex === SECTIONS.length) { + if (this.menuIndex === this.resetIndex()) { this.screen = "reset"; this.resetBuffer = ""; this.resetMismatch = false; this.render(); return; } + if (this.menuIndex === this.starPromptIndex()) { + const changed = this.state.resolveStarPrompt(); + this.onOpenGithub?.(); + if (!changed) this.render(); + return; + } const section = SECTIONS[this.menuIndex]; if (!section) return; this.sectionId = section.id; @@ -337,7 +356,19 @@ export class TutRunner implements InteractiveProgram { lines.push(` ${marker} ${label} ${progress}`); }); - const resetIndex = SECTIONS.length; + const starIndex = this.starPromptIndex(); + const starResolved = this.state.isStarPromptResolved(); + const starMarker = this.menuIndex === starIndex ? `${fg(36)}❯${RESET}` : " "; + const starLabel = + this.menuIndex === starIndex + ? `${BOLD}${STAR_PROMPT_TITLE}${RESET}` + : STAR_PROMPT_TITLE; + const starStatus = starResolved + ? `${fg(32)}[thanks ⭐]${RESET}` + : `${DIM}[not yet]${RESET}`; + lines.push(` ${starMarker} ${starLabel} ${starStatus}`); + + const resetIndex = this.resetIndex(); const resetMarker = this.menuIndex === resetIndex ? `${fg(36)}❯${RESET}` : " "; const resetLabel = this.menuIndex === resetIndex @@ -356,7 +387,7 @@ export class TutRunner implements InteractiveProgram { lines.push(` ${DIM}\`Esc\` to cancel${RESET}`); lines.push(""); lines.push( - ` This will clear all checkmarks across every section.`, + ` This will clear all checkmarks and the GitHub star prompt.`, ); lines.push( ` ${DIM}Type \`reset\` and press \`Enter\` to confirm.${RESET}`, diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts index 210f455..72d32f1 100644 --- a/website/src/lib/tutorial-state.ts +++ b/website/src/lib/tutorial-state.ts @@ -1,14 +1,18 @@ import { ALL_ITEM_IDS, ITEM_IDS, SECTIONS, type ItemId } from "./tut-items"; const STORAGE_KEY = "dormouse-tut-v3"; +const STAR_STORAGE_KEY = "dormouse-tut-star-v1"; const KNOWN_IDS: ReadonlySet = new Set(ITEM_IDS); export class TutorialState { private completed = new Set(); + private starPromptResolved = false; private listeners = new Set<() => void>(); private storage = typeof localStorage !== "undefined" ? localStorage : null; constructor() { + this.starPromptResolved = this.storage?.getItem(STAR_STORAGE_KEY) === "true"; + const raw = this.storage?.getItem(STORAGE_KEY); if (!raw) return; try { @@ -35,6 +39,18 @@ export class TutorialState { return this.completed.has(id); } + isStarPromptResolved(): boolean { + return this.starPromptResolved; + } + + resolveStarPrompt(): boolean { + if (this.starPromptResolved) return false; + this.starPromptResolved = true; + this.notify(); + this.persistStarPrompt(); + return true; + } + markComplete(id: ItemId): boolean { if (this.completed.has(id)) return false; this.completed.add(id); @@ -44,9 +60,12 @@ export class TutorialState { } reset(): void { - if (this.completed.size === 0) return; + const changed = this.completed.size > 0 || this.starPromptResolved; + if (!changed) return; this.completed.clear(); + this.starPromptResolved = false; this.storage?.removeItem(STORAGE_KEY); + this.storage?.removeItem(STAR_STORAGE_KEY); this.notify(); } @@ -61,7 +80,10 @@ export class TutorialState { } totalProgress(): { done: number; total: number } { - return { done: this.completed.size, total: ALL_ITEM_IDS.length }; + return { + done: this.completed.size + (this.starPromptResolved ? 1 : 0), + total: ALL_ITEM_IDS.length + 1, + }; } private notify(): void { @@ -77,4 +99,12 @@ export class TutorialState { // listeners already fired against the new state. } } + + private persistStarPrompt(): void { + try { + this.storage?.setItem(STAR_STORAGE_KEY, "true"); + } catch { + // Quota or access errors shouldn't break in-memory progress. + } + } } diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index a00d4df..7396a85 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -48,6 +48,14 @@ function Playground() { const busyDemoDisposeRef = useRef<(() => void) | null>(null); const alertDemoPaneIdRef = useRef(null); + const handleOpenGithub = useCallback(() => { + window.open( + "https://github.com/diffplug/dormouse", + "_blank", + "noopener,noreferrer", + ); + }, []); + const tryAutoStart = useCallback((pane: PaneSpec) => { if (autoStartedRef.current.has(pane.id)) return; const shellRegistry = shellRegistryRef.current; @@ -119,6 +127,7 @@ function Playground() { ); }, onTogglePlaceToPaste: () => setPlaceToPasteOpen((open) => !open), + onOpenGithub: handleOpenGithub, }); } if (name === "ascii-splash" || name === "splash") { @@ -173,7 +182,7 @@ function Playground() { busyDemoDisposeRef.current?.(); busyDemoDisposeRef.current = null; }; - }, [isPhone, tryAutoStart]); + }, [handleOpenGithub, isPhone, tryAutoStart]); const handleApiReady = useCallback((api: any) => { const shellRegistry = shellRegistryRef.current; diff --git a/website/src/pages/Pocket.tsx b/website/src/pages/Pocket.tsx index c4dd9b0..6b72ae6 100644 --- a/website/src/pages/Pocket.tsx +++ b/website/src/pages/Pocket.tsx @@ -73,6 +73,14 @@ function PocketTerminalExperience({ const cursorTouchAvailable = activeMouseState?.mouseReporting !== undefined && activeMouseState.mouseReporting !== "none"; + const handleOpenGithub = useCallback(() => { + window.open( + "https://github.com/diffplug/dormouse", + "_blank", + "noopener,noreferrer", + ); + }, []); + const tryAutoStart = useCallback((id: string) => { if (id !== POCKET_PANE) return; if (autoStartedRef.current.has(id)) return; @@ -120,6 +128,7 @@ function PocketTerminalExperience({ ); }, onTogglePlaceToPaste: () => {}, + onOpenGithub: handleOpenGithub, }); } if (name === "ascii-splash" || name === "splash") { @@ -161,7 +170,7 @@ function PocketTerminalExperience({ autoStartedRef.current.clear(); adapterRef.current = null; }; - }, [tryAutoStart]); + }, [handleOpenGithub, tryAutoStart]); useEffect(() => { const reporting = activeMouseState?.mouseReporting ?? "none"; From 2b794f6e099014c26aae2702c9fb485c1854d066 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 16:18:13 -0700 Subject: [PATCH 2/7] Add and enable `@xterm/addon-unicode-graphemes` --- docs/specs/layout.md | 2 +- lib/package.json | 1 + lib/src/lib/clipboard.test.ts | 5 +++++ lib/src/lib/terminal-lifecycle.ts | 3 +++ lib/src/lib/terminal-registry.alert.test.ts | 6 ++++++ pnpm-lock.yaml | 11 +++++++++++ standalone/package.json | 1 + website/src/data/dependencies.json | 7 +++++++ 8 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 4c279c8..10d88bb 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -273,7 +273,7 @@ User-pin titles must not start with `` (the sentinel that prefixes the aut Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on React mount and `unmountElement(id)` on React unmount. The session (xterm.js instance, PTY, DOM element) persists in the registry across mount/unmount cycles — the DOM element is detached from its container but the Registry entry stays `Mounted`. -- **Create**: `getOrCreateTerminal` spawns xterm.js + FitAddon + PTY, returns existing if already created +- **Create**: `getOrCreateTerminal` spawns xterm.js + UnicodeGraphemesAddon + FitAddon + PTY, returns existing if already created. The xterm instance sets `allowProposedApi: true` because UnicodeGraphemesAddon activates through xterm's proposed Unicode API. - **Resume**: `resumeTerminal` creates xterm entry and writes replay data without spawning a new PTY. Used when the webview is recreated while the host retains Live PTYs (Link: Severed → Resuming → Live). - **Restore**: `restoreTerminal` creates xterm entry and spawns a new PTY with saved cwd and scrollback. Used on cold start from a saved Snapshot (Link: Cold → Live). - **Untouched**: new `getOrCreateTerminal` sessions start untouched. `isUntouched(id)` exposes the flag, and user-originated PTY input clears it via the registry input paths. Resume/restore seed the persisted flag; missing legacy snapshot data defaults to touched (`false`) so close confirmation remains conservative. diff --git a/lib/package.json b/lib/package.json index 92bc66e..931a7a8 100644 --- a/lib/package.json +++ b/lib/package.json @@ -16,6 +16,7 @@ "dependencies": { "@phosphor-icons/react": "^2.1.10", "@xterm/addon-fit": "0.12.0-beta.219", + "@xterm/addon-unicode-graphemes": "^0.4.0", "@xterm/xterm": "6.1.0-beta.219", "clsx": "^2.1.1", "dockview-react": "^5.1.0", diff --git a/lib/src/lib/clipboard.test.ts b/lib/src/lib/clipboard.test.ts index fa97c33..239c7a2 100644 --- a/lib/src/lib/clipboard.test.ts +++ b/lib/src/lib/clipboard.test.ts @@ -20,6 +20,11 @@ vi.mock('./mouse-selection', () => ({ getMouseSelectionState: () => ({ bracketedPaste: false }), })); +vi.mock('./terminal-registry', () => ({ + getTerminalInstance: () => null, + markSessionTouched: vi.fn(), +})); + import { doPaste } from './clipboard'; describe('doPaste three-tier fallthrough', () => { diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 53b9aa6..f6089fa 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,5 +1,6 @@ import { Terminal, type IBufferRange } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; +import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes'; import { getPlatform } from './platform'; import { requestExternalLinkConfirmation } from './external-link-confirmation'; import { attachMouseModeObserver } from './mouse-mode-observer'; @@ -73,6 +74,7 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi const theme = getTerminalTheme(); const terminal = new Terminal({ + allowProposedApi: true, fontSize: editorFontSize, fontFamily: editorFontFamily, cursorBlink: true, @@ -88,6 +90,7 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi }, }); + terminal.loadAddon(new UnicodeGraphemesAddon()); const fit = new FitAddon(); terminal.loadAddon(fit); diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 8a53be3..8422d85 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -12,6 +12,12 @@ vi.mock('@xterm/addon-fit', () => { return { FitAddon }; }); +vi.mock('@xterm/addon-unicode-graphemes', () => { + class UnicodeGraphemesAddon {} + + return { UnicodeGraphemesAddon }; +}); + vi.mock('@xterm/xterm', () => { class MockTerminal { writes: string[] = []; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bc0f5e..471229e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@xterm/addon-fit': specifier: 0.12.0-beta.219 version: 0.12.0-beta.219(@xterm/xterm@6.1.0-beta.219) + '@xterm/addon-unicode-graphemes': + specifier: ^0.4.0 + version: 0.4.0 '@xterm/xterm': specifier: 6.1.0-beta.219 version: 6.1.0-beta.219 @@ -98,6 +101,9 @@ importers: '@xterm/addon-fit': specifier: 0.12.0-beta.219 version: 0.12.0-beta.219(@xterm/xterm@6.1.0-beta.219) + '@xterm/addon-unicode-graphemes': + specifier: ^0.4.0 + version: 0.4.0 '@xterm/xterm': specifier: 6.1.0-beta.219 version: 6.1.0-beta.219 @@ -2071,6 +2077,9 @@ packages: peerDependencies: '@xterm/xterm': ^6.1.0-beta.219 + '@xterm/addon-unicode-graphemes@0.4.0': + resolution: {integrity: sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==} + '@xterm/xterm@6.1.0-beta.219': resolution: {integrity: sha512-cQDv5UMEooIqjlybDPfNO0uI0sB0SWaaOxubHcFNXah8umLHR8SfKQbj5BPaa5gqoTKGYFkVdpowyhZlV+xywQ==} @@ -5567,6 +5576,8 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.219 + '@xterm/addon-unicode-graphemes@0.4.0': {} + '@xterm/xterm@6.1.0-beta.219': {} acorn@8.16.0: {} diff --git a/standalone/package.json b/standalone/package.json index 8a097ba..498af08 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -16,6 +16,7 @@ "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "0.12.0-beta.219", + "@xterm/addon-unicode-graphemes": "^0.4.0", "@xterm/xterm": "6.1.0-beta.219", "dockview-react": "^5.1.0", "dormouse-lib": "workspace:*", diff --git a/website/src/data/dependencies.json b/website/src/data/dependencies.json index 1a73cfc..624c7ce 100644 --- a/website/src/data/dependencies.json +++ b/website/src/data/dependencies.json @@ -34,6 +34,13 @@ "author": "The xterm.js authors", "homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme" }, + { + "name": "@xterm/addon-unicode-graphemes", + "version": "0.4.0", + "license": "MIT", + "author": "The xterm.js authors", + "homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme" + }, { "name": "@xterm/xterm", "version": "6.1.0-beta.219", From 31cf4f64e3e83f9f49dd3c2228763a9b98f422b0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 19 May 2026 01:16:04 -0700 Subject: [PATCH 3/7] Use beta xterm graphemes addon --- lib/package.json | 2 +- pnpm-lock.yaml | 18 +++++++++++------- standalone/package.json | 2 +- website/src/data/dependencies.json | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/package.json b/lib/package.json index 931a7a8..d3916eb 100644 --- a/lib/package.json +++ b/lib/package.json @@ -16,7 +16,7 @@ "dependencies": { "@phosphor-icons/react": "^2.1.10", "@xterm/addon-fit": "0.12.0-beta.219", - "@xterm/addon-unicode-graphemes": "^0.4.0", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.219", "@xterm/xterm": "6.1.0-beta.219", "clsx": "^2.1.1", "dockview-react": "^5.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 471229e..1f0465d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,8 +17,8 @@ importers: specifier: 0.12.0-beta.219 version: 0.12.0-beta.219(@xterm/xterm@6.1.0-beta.219) '@xterm/addon-unicode-graphemes': - specifier: ^0.4.0 - version: 0.4.0 + specifier: 0.5.0-beta.219 + version: 0.5.0-beta.219(@xterm/xterm@6.1.0-beta.219) '@xterm/xterm': specifier: 6.1.0-beta.219 version: 6.1.0-beta.219 @@ -102,8 +102,8 @@ importers: specifier: 0.12.0-beta.219 version: 0.12.0-beta.219(@xterm/xterm@6.1.0-beta.219) '@xterm/addon-unicode-graphemes': - specifier: ^0.4.0 - version: 0.4.0 + specifier: 0.5.0-beta.219 + version: 0.5.0-beta.219(@xterm/xterm@6.1.0-beta.219) '@xterm/xterm': specifier: 6.1.0-beta.219 version: 6.1.0-beta.219 @@ -2077,8 +2077,10 @@ packages: peerDependencies: '@xterm/xterm': ^6.1.0-beta.219 - '@xterm/addon-unicode-graphemes@0.4.0': - resolution: {integrity: sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==} + '@xterm/addon-unicode-graphemes@0.5.0-beta.219': + resolution: {integrity: sha512-507MpY9VdIdarrZtszYt8HPr5cJr1MMNKFk0AyyzJmtYq8cVQdDXHO7ukpmdaZ6v5Z5H/4+wnJgvTPBXu2prTg==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.219 '@xterm/xterm@6.1.0-beta.219': resolution: {integrity: sha512-cQDv5UMEooIqjlybDPfNO0uI0sB0SWaaOxubHcFNXah8umLHR8SfKQbj5BPaa5gqoTKGYFkVdpowyhZlV+xywQ==} @@ -5576,7 +5578,9 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.219 - '@xterm/addon-unicode-graphemes@0.4.0': {} + '@xterm/addon-unicode-graphemes@0.5.0-beta.219(@xterm/xterm@6.1.0-beta.219)': + dependencies: + '@xterm/xterm': 6.1.0-beta.219 '@xterm/xterm@6.1.0-beta.219': {} diff --git a/standalone/package.json b/standalone/package.json index 498af08..1dbd6c2 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -16,7 +16,7 @@ "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "0.12.0-beta.219", - "@xterm/addon-unicode-graphemes": "^0.4.0", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.219", "@xterm/xterm": "6.1.0-beta.219", "dockview-react": "^5.1.0", "dormouse-lib": "workspace:*", diff --git a/website/src/data/dependencies.json b/website/src/data/dependencies.json index 624c7ce..d0fcc5e 100644 --- a/website/src/data/dependencies.json +++ b/website/src/data/dependencies.json @@ -36,7 +36,7 @@ }, { "name": "@xterm/addon-unicode-graphemes", - "version": "0.4.0", + "version": "0.5.0-beta.219", "license": "MIT", "author": "The xterm.js authors", "homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme" From 2d757fd051bd802d669f8f01308d383e23e16cb9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 19 May 2026 13:05:58 -0700 Subject: [PATCH 4/7] Add locked Burrow tutorial entry --- docs/specs/tutorial.md | 6 +- .../lib/__snapshots__/tut-runner.test.ts.snap | 3 +- website/src/lib/tut-runner.test.ts | 26 +++++- website/src/lib/tut-runner.ts | 82 +++++++++++++++++-- website/src/lib/tutorial-state.ts | 5 +- 5 files changed, 110 insertions(+), 12 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index c0e2155..5c4b00b 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -29,7 +29,11 @@ Every playground pane gets a `TutorialShell` input handler through `PlaygroundSh ## Tutorial Sections -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 top-level menu also includes `Starred on GitHub`, which participates in the overall progress total but not in any section count, 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 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")`. + +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 `🧀 The Burrow 🧀`. Pressing Enter on the unlocked row opens a short runner-local animation; `Esc` returns to the top-level menu. Inside a section, items render as one of: diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index e97417d..22d30d3 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -79,12 +79,13 @@ exports[`TutRunner snapshots > renders Keyboard navigation with all items incomp exports[`TutRunner snapshots > renders the top-level menu 1`] = ` " Dormouse Playground Tutorial - 0/18 complete · Esc/q to exit · Enter to open · ↑↓ to navigate + Esc/q to exit · Enter to open · ↑↓ to navigate ❯ Keyboard navigation [0/7 complete] Alert and TODO [0/6 complete] Copy paste [0/4 complete] Starred on GitHub [not yet] + 🧀 ??? 🧀 [LOCKED 0/17] Reset progress diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 7e82b74..99a8ed1 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -112,10 +112,34 @@ describe("TutRunner snapshots", () => { const { state, sendKeys, dispose } = mountRunner(["kb-mode"]); state.resolveStarPrompt(); - sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\rreset\r"); + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\rreset\r"); expect(state.isComplete("kb-mode")).toBe(false); expect(state.isStarPromptResolved()).toBe(false); dispose(); }); + + it("keeps the Burrow locked until every tutorial task is complete", () => { + const { sendKeys, lastFrame, dispose } = mountRunner(); + + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r"); + + expect(lastFrame()).toContain("🧀 ??? 🧀"); + expect(lastFrame()).toContain("[LOCKED 0/17]"); + expect(lastFrame()).toContain("Dormouse Playground Tutorial"); + dispose(); + }); + + it("opens and exits the Burrow after every tutorial task is complete", () => { + const allItemIds = SECTIONS.flatMap((section) => section.items.map((i) => i.id)); + const { sendKeys, lastFrame, dispose } = mountRunner(allItemIds); + + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r"); + expect(lastFrame()).toContain("🧀 The Burrow 🧀"); + expect(lastFrame()).toContain("__/--\\__"); + + sendKeys("\x1b"); + expect(lastFrame()).toContain("Dormouse Playground Tutorial"); + dispose(); + }); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index b24183f..33de8c3 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -54,6 +54,8 @@ const SPINNER_INTERVAL_MS = 100; */ const ACTIVE_ITEM_GLYPH = "●"; const STAR_PROMPT_TITLE = "Starred on GitHub"; +const BURROW_LOCKED_TITLE = "🧀 ??? 🧀"; +const BURROW_UNLOCKED_TITLE = "🧀 The Burrow 🧀"; interface TutRunnerOptions { adapter: FakePtyAdapter; @@ -68,7 +70,7 @@ interface TutRunnerOptions { onOpenGithub?: () => void; } -type Screen = "menu" | "section" | "reset"; +type Screen = "menu" | "section" | "reset" | "burrow"; const RESET_CONFIRM_WORD = "reset"; @@ -88,6 +90,8 @@ export class TutRunner implements InteractiveProgram { private resetMismatch = false; private spinnerFrame = 0; private spinnerTimer: ReturnType | null = null; + private burrowFrame = 0; + private burrowTimer: ReturnType | null = null; private stateUnsub: (() => void) | null = null; private resizeUnsub: (() => void) | null = null; private busyDemoStart: number | null = null; @@ -132,6 +136,20 @@ export class TutRunner implements InteractiveProgram { this.spinnerTimer = null; } + private startBurrowTicks(): void { + if (this.burrowTimer) return; + this.burrowTimer = setInterval(() => { + this.burrowFrame = (this.burrowFrame + 1) % 12; + this.render(); + }, 180); + } + + private stopBurrowTicks(): void { + if (!this.burrowTimer) return; + clearInterval(this.burrowTimer); + this.burrowTimer = null; + } + handleInput(data: string): void { if (this.disposed) return; let i = 0; @@ -210,18 +228,22 @@ export class TutRunner implements InteractiveProgram { // --- Input --- private menuLength(): number { - // SECTIONS + the GitHub star prompt + the trailing "Reset progress" entry - return SECTIONS.length + 2; + // SECTIONS + GitHub star + Burrow + the trailing "Reset progress" entry + return SECTIONS.length + 3; } private starPromptIndex(): number { return SECTIONS.length; } - private resetIndex(): number { + private burrowIndex(): number { return SECTIONS.length + 1; } + private resetIndex(): number { + return SECTIONS.length + 2; + } + private handleArrow(letter: string): void { if (this.screen !== "menu") return; const len = this.menuLength(); @@ -250,6 +272,18 @@ export class TutRunner implements InteractiveProgram { if (!changed) this.render(); return; } + if (this.menuIndex === this.burrowIndex()) { + const { done, total } = this.state.totalProgress(); + if (done !== total) { + this.render(); + return; + } + this.screen = "burrow"; + this.burrowFrame = 0; + this.startBurrowTicks(); + this.render(); + return; + } const section = SECTIONS[this.menuIndex]; if (!section) return; this.sectionId = section.id; @@ -297,6 +331,12 @@ export class TutRunner implements InteractiveProgram { this.render(); return; } + if (this.screen === "burrow") { + this.stopBurrowTicks(); + this.screen = "menu"; + this.render(); + return; + } this.exit(); } @@ -326,6 +366,8 @@ export class TutRunner implements InteractiveProgram { ? this.renderMenu() : this.screen === "reset" ? this.renderReset() + : this.screen === "burrow" + ? this.renderBurrow() : this.renderSection(); let out = `${CURSOR_HOME}${CLEAR_SCREEN}`; for (const line of lines) { @@ -340,7 +382,7 @@ export class TutRunner implements InteractiveProgram { lines.push(""); lines.push(` ${BOLD}Dormouse Playground Tutorial${RESET}`); lines.push( - ` ${DIM}${total.done}/${total.total} complete · \`Esc\`/\`q\` to exit · \`Enter\` to open · \`↑↓\` to navigate${RESET}`, + ` ${DIM}\`Esc\`/\`q\` to exit · \`Enter\` to open · \`↑↓\` to navigate${RESET}`, ); lines.push(""); SECTIONS.forEach((section, index) => { @@ -368,6 +410,19 @@ export class TutRunner implements InteractiveProgram { : `${DIM}[not yet]${RESET}`; lines.push(` ${starMarker} ${starLabel} ${starStatus}`); + const burrowIndex = this.burrowIndex(); + const burrowUnlocked = total.done === total.total; + const burrowMarker = this.menuIndex === burrowIndex ? `${fg(36)}❯${RESET}` : " "; + const burrowTitle = burrowUnlocked ? BURROW_UNLOCKED_TITLE : BURROW_LOCKED_TITLE; + const burrowLabel = + this.menuIndex === burrowIndex + ? `${BOLD}${burrowTitle}${RESET}` + : burrowTitle; + const burrowStatus = burrowUnlocked + ? "" + : ` ${DIM}[LOCKED ${total.done}/${total.total}]${RESET}`; + lines.push(` ${burrowMarker} ${burrowLabel}${burrowStatus}`); + const resetIndex = this.resetIndex(); const resetMarker = this.menuIndex === resetIndex ? `${fg(36)}❯${RESET}` : " "; const resetLabel = @@ -380,6 +435,22 @@ export class TutRunner implements InteractiveProgram { return lines; } + private renderBurrow(): string[] { + const glint = "·".repeat((this.burrowFrame % 4) + 1); + const offset = this.burrowFrame % 6; + const path = `${" ".repeat(offset)}*${" ".repeat(5 - offset)}`; + return [ + "", + ` ${BOLD}${BURROW_UNLOCKED_TITLE}${RESET}`, + ` ${DIM}\`Esc\` to go back${RESET}`, + "", + ` ${fg(33)}${glint}${RESET}`, + ` __/--\\__`, + ` __/ ${fg(36)}${path}${RESET} \\__`, + ` /________________\\`, + ]; + } + private renderReset(): string[] { const lines: string[] = []; lines.push(""); @@ -536,6 +607,7 @@ export class TutRunner implements InteractiveProgram { if (this.disposed) return; this.disposed = true; this.stopSpinnerTicks(); + this.stopBurrowTicks(); this.busyDemoStart = null; this.stateUnsub?.(); this.stateUnsub = null; diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts index 72d32f1..42cdde6 100644 --- a/website/src/lib/tutorial-state.ts +++ b/website/src/lib/tutorial-state.ts @@ -80,10 +80,7 @@ export class TutorialState { } totalProgress(): { done: number; total: number } { - return { - done: this.completed.size + (this.starPromptResolved ? 1 : 0), - total: ALL_ITEM_IDS.length + 1, - }; + return { done: this.completed.size, total: ALL_ITEM_IDS.length }; } private notify(): void { From eb94378a4ae7ede4c44dbedcd4200298c4c50a2f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 21 May 2026 11:43:33 -0700 Subject: [PATCH 5/7] Replace Burrow with Flappy Term tutorial reward Port the xterm.js Flappy Bird prototype into the tutorial runner as the hidden reward unlocked by completing every section. The entry stays locked until total progress hits 100%, then surfaces a persistent high score in the menu. Co-Authored-By: Claude Opus 4.7 --- .../lib/__snapshots__/tut-runner.test.ts.snap | 2 +- website/src/lib/tut-runner.test.ts | 24 +- website/src/lib/tut-runner.ts | 370 +++++++++++++++--- website/src/lib/tutorial-state.ts | 35 +- 4 files changed, 374 insertions(+), 57 deletions(-) diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 22d30d3..d0e9ffc 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -85,7 +85,7 @@ exports[`TutRunner snapshots > renders the top-level menu 1`] = ` Alert and TODO [0/6 complete] Copy paste [0/4 complete] Starred on GitHub [not yet] - 🧀 ??? 🧀 [LOCKED 0/17] + 🐭 ??? 🐭 [LOCKED 0/17] Reset progress diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 99a8ed1..69ce45e 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -119,24 +119,38 @@ describe("TutRunner snapshots", () => { dispose(); }); - it("keeps the Burrow locked until every tutorial task is complete", () => { + it("keeps Flappy Term locked until every tutorial task is complete", () => { const { sendKeys, lastFrame, dispose } = mountRunner(); sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r"); - expect(lastFrame()).toContain("🧀 ??? 🧀"); + expect(lastFrame()).toContain("🐭 ??? 🐭"); expect(lastFrame()).toContain("[LOCKED 0/17]"); expect(lastFrame()).toContain("Dormouse Playground Tutorial"); dispose(); }); - it("opens and exits the Burrow after every tutorial task is complete", () => { + it("shows the unlocked Flappy Term entry with a high-score readout", () => { + const allItemIds = SECTIONS.flatMap((section) => section.items.map((i) => i.id)); + const { state, sendKeys, lastFrame, dispose } = mountRunner(allItemIds); + state.recordFlappyScore(7); + + // Navigate to (but don't enter) the Flappy Term row. + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B"); + expect(lastFrame()).toContain("🐭 Flappy Term 🐭"); + expect(lastFrame()).toContain("[High score: 7]"); + dispose(); + }); + + it("opens Flappy Term, shows the start hint, and exits back to the menu", () => { const allItemIds = SECTIONS.flatMap((section) => section.items.map((i) => i.id)); const { sendKeys, lastFrame, dispose } = mountRunner(allItemIds); sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r"); - expect(lastFrame()).toContain("🧀 The Burrow 🧀"); - expect(lastFrame()).toContain("__/--\\__"); + const frame = lastFrame(); + expect(frame).toContain("Score: 0"); + expect(frame).toContain("Best:"); + expect(frame).toContain("Space / Up to flap"); sendKeys("\x1b"); expect(lastFrame()).toContain("Dormouse Playground Tutorial"); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 33de8c3..7325928 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -54,8 +54,54 @@ const SPINNER_INTERVAL_MS = 100; */ const ACTIVE_ITEM_GLYPH = "●"; const STAR_PROMPT_TITLE = "Starred on GitHub"; -const BURROW_LOCKED_TITLE = "🧀 ??? 🧀"; -const BURROW_UNLOCKED_TITLE = "🧀 The Burrow 🧀"; +const FLAPPY_LOCKED_TITLE = "🐭 ??? 🐭"; +const FLAPPY_UNLOCKED_TITLE = "🐭 Flappy Term 🐭"; + +// --- Flappy Term game constants (ported from flappy-term.html) --- +const FLAPPY_TICK_MS = 60; +const FLAPPY_GRAVITY = 0.18; +const FLAPPY_FLAP_V = -1.1; +const FLAPPY_MAX_VY = 1.3; +const FLAPPY_PIPE_SPACING_MIN = 18; +const FLAPPY_PIPE_GAP = 8; +const FLAPPY_MAX_GAP_DELTA = 8; +const FLAPPY_PIPE_W = 4; +const FLAPPY_GROUND_H = 2; + +const fg256 = (n: number) => `\x1b[38;5;${n}m`; +const bg256 = (n: number) => `\x1b[48;5;${n}m`; +const moveTo = (row: number, col: number) => `\x1b[${row};${col}H`; + +const FLAPPY_COLORS = { + bird: 226, + birdO: 208, + pipe: 34, + pipeD: 22, + ground: 94, + grass: 40, + text: 231, + dim: 245, + red: 196, +}; + +interface FlappyPipe { + x: number; + gapY: number; + gapH: number; + scored: boolean; +} + +interface FlappyGameState { + birdX: number; + birdY: number; + birdVY: number; + pipes: FlappyPipe[]; + score: number; + alive: boolean; + started: boolean; + frame: number; + nextPipeIn: number; +} interface TutRunnerOptions { adapter: FakePtyAdapter; @@ -70,7 +116,7 @@ interface TutRunnerOptions { onOpenGithub?: () => void; } -type Screen = "menu" | "section" | "reset" | "burrow"; +type Screen = "menu" | "section" | "reset" | "flappy"; const RESET_CONFIRM_WORD = "reset"; @@ -90,8 +136,8 @@ export class TutRunner implements InteractiveProgram { private resetMismatch = false; private spinnerFrame = 0; private spinnerTimer: ReturnType | null = null; - private burrowFrame = 0; - private burrowTimer: ReturnType | null = null; + private flappyTimer: ReturnType | null = null; + private flappy: FlappyGameState | null = null; private stateUnsub: (() => void) | null = null; private resizeUnsub: (() => void) | null = null; private busyDemoStart: number | null = null; @@ -136,18 +182,113 @@ export class TutRunner implements InteractiveProgram { this.spinnerTimer = null; } - private startBurrowTicks(): void { - if (this.burrowTimer) return; - this.burrowTimer = setInterval(() => { - this.burrowFrame = (this.burrowFrame + 1) % 12; + private startFlappyTicks(): void { + if (this.flappyTimer) return; + this.flappyTimer = setInterval(() => { + this.stepFlappy(); this.render(); - }, 180); + }, FLAPPY_TICK_MS); + } + + private stopFlappyTicks(): void { + if (!this.flappyTimer) return; + clearInterval(this.flappyTimer); + this.flappyTimer = null; + } + + private resetFlappyGame(): void { + const { cols, rows } = this.adapter.getPtySize(this.terminalId); + const playH = Math.max(6, rows - FLAPPY_GROUND_H); + this.flappy = { + birdX: Math.max(2, Math.floor(cols * 0.25)), + birdY: Math.floor(playH / 2), + birdVY: 0, + pipes: [], + score: 0, + alive: true, + started: false, + frame: 0, + nextPipeIn: 10, + }; } - private stopBurrowTicks(): void { - if (!this.burrowTimer) return; - clearInterval(this.burrowTimer); - this.burrowTimer = null; + private flap(): void { + const g = this.flappy; + if (!g || !g.alive) return; + g.started = true; + g.birdVY = FLAPPY_FLAP_V; + } + + private spawnFlappyPipe(cols: number, playH: number): void { + const g = this.flappy; + if (!g) return; + const minGapY = 3; + const maxGapY = playH - FLAPPY_PIPE_GAP - 3; + let lo = minGapY; + let hi = maxGapY; + const last = g.pipes[g.pipes.length - 1]; + if (last) { + lo = Math.max(lo, last.gapY - FLAPPY_MAX_GAP_DELTA); + hi = Math.min(hi, last.gapY + FLAPPY_MAX_GAP_DELTA); + } + if (hi < lo) hi = lo; + const gapY = Math.floor(lo + Math.random() * (hi - lo + 1)); + g.pipes.push({ + x: cols + 1, + gapY, + gapH: FLAPPY_PIPE_GAP, + scored: false, + }); + } + + private stepFlappy(): void { + const g = this.flappy; + if (!g || !g.alive) return; + const { cols, rows } = this.adapter.getPtySize(this.terminalId); + const playH = Math.max(6, rows - FLAPPY_GROUND_H); + + g.frame++; + if (!g.started) return; + + g.birdVY += FLAPPY_GRAVITY; + if (g.birdVY > FLAPPY_MAX_VY) g.birdVY = FLAPPY_MAX_VY; + g.birdY += g.birdVY; + + for (const p of g.pipes) p.x -= 1; + g.pipes = g.pipes.filter((p) => p.x + FLAPPY_PIPE_W > 0); + + g.nextPipeIn--; + if (g.nextPipeIn <= 0) { + this.spawnFlappyPipe(cols, playH); + g.nextPipeIn = FLAPPY_PIPE_SPACING_MIN; + } + + const bx = Math.round(g.birdX); + for (const p of g.pipes) { + if (!p.scored && p.x + FLAPPY_PIPE_W - 1 < bx) { + p.scored = true; + g.score++; + this.state.recordFlappyScore(g.score); + } + } + + const by = Math.round(g.birdY); + if (by >= playH) { + g.birdY = playH - 1; + g.alive = false; + } else if (by < 0) { + // Bonk the ceiling but don't die — feels better than instant death. + g.birdY = 0; + g.birdVY = 0.2; + } + + for (const p of g.pipes) { + if (bx >= p.x && bx < p.x + FLAPPY_PIPE_W) { + if (by < p.gapY || by >= p.gapY + p.gapH) { + g.alive = false; + } + } + } } handleInput(data: string): void { @@ -192,6 +333,11 @@ export class TutRunner implements InteractiveProgram { i += 1; continue; } + if (this.screen === "flappy" && ch === " ") { + this.flap(); + i += 1; + continue; + } if (ch === "q" || ch === "Q") { this.handleEscape(); return; @@ -228,7 +374,7 @@ export class TutRunner implements InteractiveProgram { // --- Input --- private menuLength(): number { - // SECTIONS + GitHub star + Burrow + the trailing "Reset progress" entry + // SECTIONS + GitHub star + Flappy Term + the trailing "Reset progress" entry return SECTIONS.length + 3; } @@ -236,7 +382,7 @@ export class TutRunner implements InteractiveProgram { return SECTIONS.length; } - private burrowIndex(): number { + private flappyIndex(): number { return SECTIONS.length + 1; } @@ -245,6 +391,10 @@ export class TutRunner implements InteractiveProgram { } private handleArrow(letter: string): void { + if (this.screen === "flappy") { + if (letter === "A") this.flap(); + return; + } if (this.screen !== "menu") return; const len = this.menuLength(); if (letter === "A") { @@ -258,6 +408,15 @@ export class TutRunner implements InteractiveProgram { } private handleEnter(): void { + if (this.screen === "flappy") { + if (this.flappy && !this.flappy.alive) { + this.resetFlappyGame(); + this.render(); + } else { + this.flap(); + } + return; + } if (this.screen === "menu") { if (this.menuIndex === this.resetIndex()) { this.screen = "reset"; @@ -272,15 +431,19 @@ export class TutRunner implements InteractiveProgram { if (!changed) this.render(); return; } - if (this.menuIndex === this.burrowIndex()) { + if (this.menuIndex === this.flappyIndex()) { const { done, total } = this.state.totalProgress(); if (done !== total) { this.render(); return; } - this.screen = "burrow"; - this.burrowFrame = 0; - this.startBurrowTicks(); + this.screen = "flappy"; + this.resetFlappyGame(); + // One full clear when entering the game so leftover menu chrome + // doesn't peek through the diff-style game frames (which only + // CURSOR_HOME between ticks to avoid flicker). + this.write(CLEAR_SCREEN + CURSOR_HOME); + this.startFlappyTicks(); this.render(); return; } @@ -331,8 +494,9 @@ export class TutRunner implements InteractiveProgram { this.render(); return; } - if (this.screen === "burrow") { - this.stopBurrowTicks(); + if (this.screen === "flappy") { + this.stopFlappyTicks(); + this.flappy = null; this.screen = "menu"; this.render(); return; @@ -361,13 +525,15 @@ export class TutRunner implements InteractiveProgram { private render(): void { if (this.disposed) return; + if (this.screen === "flappy") { + this.renderFlappy(); + return; + } const lines = this.screen === "menu" ? this.renderMenu() : this.screen === "reset" ? this.renderReset() - : this.screen === "burrow" - ? this.renderBurrow() : this.renderSection(); let out = `${CURSOR_HOME}${CLEAR_SCREEN}`; for (const line of lines) { @@ -410,18 +576,18 @@ export class TutRunner implements InteractiveProgram { : `${DIM}[not yet]${RESET}`; lines.push(` ${starMarker} ${starLabel} ${starStatus}`); - const burrowIndex = this.burrowIndex(); - const burrowUnlocked = total.done === total.total; - const burrowMarker = this.menuIndex === burrowIndex ? `${fg(36)}❯${RESET}` : " "; - const burrowTitle = burrowUnlocked ? BURROW_UNLOCKED_TITLE : BURROW_LOCKED_TITLE; - const burrowLabel = - this.menuIndex === burrowIndex - ? `${BOLD}${burrowTitle}${RESET}` - : burrowTitle; - const burrowStatus = burrowUnlocked - ? "" + const flappyIndex = this.flappyIndex(); + const flappyUnlocked = total.done === total.total; + const flappyMarker = this.menuIndex === flappyIndex ? `${fg(36)}❯${RESET}` : " "; + const flappyTitle = flappyUnlocked ? FLAPPY_UNLOCKED_TITLE : FLAPPY_LOCKED_TITLE; + const flappyLabel = + this.menuIndex === flappyIndex + ? `${BOLD}${flappyTitle}${RESET}` + : flappyTitle; + const flappyStatus = flappyUnlocked + ? ` ${fg(32)}[High score: ${this.state.getFlappyHighScore()}]${RESET}` : ` ${DIM}[LOCKED ${total.done}/${total.total}]${RESET}`; - lines.push(` ${burrowMarker} ${burrowLabel}${burrowStatus}`); + lines.push(` ${flappyMarker} ${flappyLabel}${flappyStatus}`); const resetIndex = this.resetIndex(); const resetMarker = this.menuIndex === resetIndex ? `${fg(36)}❯${RESET}` : " "; @@ -435,20 +601,123 @@ export class TutRunner implements InteractiveProgram { return lines; } - private renderBurrow(): string[] { - const glint = "·".repeat((this.burrowFrame % 4) + 1); - const offset = this.burrowFrame % 6; - const path = `${" ".repeat(offset)}*${" ".repeat(5 - offset)}`; - return [ - "", - ` ${BOLD}${BURROW_UNLOCKED_TITLE}${RESET}`, - ` ${DIM}\`Esc\` to go back${RESET}`, - "", - ` ${fg(33)}${glint}${RESET}`, - ` __/--\\__`, - ` __/ ${fg(36)}${path}${RESET} \\__`, - ` /________________\\`, - ]; + private renderFlappy(): void { + const g = this.flappy; + if (!g) return; + const { cols, rows } = this.adapter.getPtySize(this.terminalId); + const COLS = cols; + const ROWS = rows; + const playH = Math.max(6, ROWS - FLAPPY_GROUND_H); + const C = FLAPPY_COLORS; + + // Use CURSOR_HOME (no CLEAR_SCREEN) per-frame to avoid the blank + // flash xterm would otherwise paint between frames; the full grid + // overwrites every cell so leftover content can't show through. + let out = CURSOR_HOME; + + for (let r = 0; r < ROWS; r++) { + let row = ""; + let currentColor = ""; + const setColor = (color: string) => { + if (color !== currentColor) { + row += color; + currentColor = color; + } + }; + + for (let c = 0; c < COLS; c++) { + // Ground area + if (r >= playH) { + if (r === playH) { + setColor(RESET + fg256(C.grass)); + row += "^"; + } else { + setColor(RESET + fg256(C.ground)); + row += "~"; + } + continue; + } + + // Pipes + let drewPipe = false; + for (const p of g.pipes) { + if (c >= p.x && c < p.x + FLAPPY_PIPE_W) { + const inGap = r >= p.gapY && r < p.gapY + p.gapH; + if (!inGap) { + const isEdge = c === p.x || c === p.x + FLAPPY_PIPE_W - 1; + const isCapTop = r === p.gapY - 1; + const isCapBot = r === p.gapY + p.gapH; + if (isCapTop || isCapBot) { + setColor(fg256(C.pipeD) + bg256(C.pipe)); + row += "="; + } else if (isEdge) { + setColor(fg256(C.pipeD) + bg256(C.pipe)); + row += "|"; + } else { + setColor(bg256(C.pipe) + fg256(C.pipeD)); + row += " "; + } + drewPipe = true; + break; + } + } + } + if (drewPipe) continue; + + // Bird + const bx = Math.round(g.birdX); + const by = Math.round(g.birdY); + if (r === by && c === bx) { + setColor(RESET + fg256(C.bird) + BOLD); + let glyph = ">"; + if (g.birdVY < -0.3) glyph = "^"; + else if (g.birdVY > 0.4) glyph = "v"; + row += glyph; + continue; + } + if (r === by && c === bx + 1) { + setColor(RESET + fg256(C.birdO) + BOLD); + row += ">"; + continue; + } + + setColor(RESET); + row += " "; + } + + out += row + RESET; + if (r < ROWS - 1) out += "\r\n"; + } + + // Overlays (absolute-positioned over the grid) + const scoreStr = `Score: ${g.score}`; + const bestStr = `Best: ${this.state.getFlappyHighScore()}`; + out += moveTo(1, 2) + RESET + fg256(C.text) + BOLD + scoreStr + RESET; + out += moveTo(1, Math.max(2, COLS - bestStr.length - 1)) + fg256(C.dim) + bestStr + RESET; + + if (!g.alive) { + const r = Math.max(1, Math.floor(ROWS / 2) - 2); + out += this.flappyCenteredAt(COLS, r, "+--------------------+", fg256(C.red) + BOLD); + out += this.flappyCenteredAt(COLS, r + 1, "| GAME OVER |", fg256(C.red) + BOLD); + out += this.flappyCenteredAt(COLS, r + 2, "+--------------------+", fg256(C.red) + BOLD); + out += this.flappyCenteredAt( + COLS, + r + 4, + `Score: ${g.score} Best: ${this.state.getFlappyHighScore()}`, + fg256(C.text), + ); + out += this.flappyCenteredAt(COLS, r + 6, "[ Enter to restart · Esc to exit ]", fg256(C.dim)); + } else if (!g.started) { + const r = Math.min(ROWS, Math.floor(ROWS / 2) + 2); + out += this.flappyCenteredAt(COLS, r, "[ Space / Up to flap · Esc to exit ]", fg256(C.dim)); + } + + this.write(out); + } + + private flappyCenteredAt(cols: number, row: number, text: string, color: string): string { + const c = Math.max(1, Math.floor((cols - text.length) / 2) + 1); + return moveTo(row, c) + color + text + RESET; } private renderReset(): string[] { @@ -607,7 +876,8 @@ export class TutRunner implements InteractiveProgram { if (this.disposed) return; this.disposed = true; this.stopSpinnerTicks(); - this.stopBurrowTicks(); + this.stopFlappyTicks(); + this.flappy = null; this.busyDemoStart = null; this.stateUnsub?.(); this.stateUnsub = null; diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts index 42cdde6..33ffab6 100644 --- a/website/src/lib/tutorial-state.ts +++ b/website/src/lib/tutorial-state.ts @@ -2,17 +2,25 @@ import { ALL_ITEM_IDS, ITEM_IDS, SECTIONS, type ItemId } from "./tut-items"; const STORAGE_KEY = "dormouse-tut-v3"; const STAR_STORAGE_KEY = "dormouse-tut-star-v1"; +const FLAPPY_HIGH_SCORE_KEY = "dormouse-flappy-high-v1"; const KNOWN_IDS: ReadonlySet = new Set(ITEM_IDS); export class TutorialState { private completed = new Set(); private starPromptResolved = false; + private flappyHighScore = 0; private listeners = new Set<() => void>(); private storage = typeof localStorage !== "undefined" ? localStorage : null; constructor() { this.starPromptResolved = this.storage?.getItem(STAR_STORAGE_KEY) === "true"; + const high = this.storage?.getItem(FLAPPY_HIGH_SCORE_KEY); + if (high) { + const parsed = Number.parseInt(high, 10); + if (Number.isFinite(parsed) && parsed >= 0) this.flappyHighScore = parsed; + } + const raw = this.storage?.getItem(STORAGE_KEY); if (!raw) return; try { @@ -60,13 +68,30 @@ export class TutorialState { } reset(): void { - const changed = this.completed.size > 0 || this.starPromptResolved; + const changed = + this.completed.size > 0 || + this.starPromptResolved || + this.flappyHighScore > 0; if (!changed) return; this.completed.clear(); this.starPromptResolved = false; + this.flappyHighScore = 0; this.storage?.removeItem(STORAGE_KEY); this.storage?.removeItem(STAR_STORAGE_KEY); + this.storage?.removeItem(FLAPPY_HIGH_SCORE_KEY); + this.notify(); + } + + getFlappyHighScore(): number { + return this.flappyHighScore; + } + + recordFlappyScore(score: number): boolean { + if (!Number.isFinite(score) || score <= this.flappyHighScore) return false; + this.flappyHighScore = Math.floor(score); + this.persistFlappyHighScore(); this.notify(); + return true; } sectionProgress(sectionId: string): { done: number; total: number } { @@ -104,4 +129,12 @@ export class TutorialState { // Quota or access errors shouldn't break in-memory progress. } } + + private persistFlappyHighScore(): void { + try { + this.storage?.setItem(FLAPPY_HIGH_SCORE_KEY, String(this.flappyHighScore)); + } catch { + // Quota or access errors shouldn't break in-memory progress. + } + } } From 3bf18dc86cef7fa7edd8eceef68ffdf343feef50 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 21 May 2026 19:24:07 -0700 Subject: [PATCH 6/7] Add pocket prompt to Flappy Term game-over screen Surface dormouse.sh/pocket as a follow-up on death so players see the phone build right when they're already invested in the game. `p` opens it in a new tab via the same window.open pattern as the GitHub link. Co-Authored-By: Claude Opus 4.7 --- website/src/lib/tut-runner.ts | 22 +++++++++++++++++++++- website/src/pages/Playground.tsx | 5 +++++ website/src/pages/Pocket.tsx | 5 +++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 7325928..c335794 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -114,6 +114,8 @@ interface TutRunnerOptions { onTogglePlaceToPaste?: () => void; /** Called when the user presses `Enter` on the GitHub star prompt. */ onOpenGithub?: () => void; + /** Called when the user presses `p` on the Flappy Term game-over screen. */ + onOpenPocket?: () => void; } type Screen = "menu" | "section" | "reset" | "flappy"; @@ -128,6 +130,7 @@ export class TutRunner implements InteractiveProgram { private onTriggerBusyDemo?: () => void; private onTogglePlaceToPaste?: () => void; private onOpenGithub?: () => void; + private onOpenPocket?: () => void; private screen: Screen = "menu"; private menuIndex = 0; @@ -151,6 +154,7 @@ export class TutRunner implements InteractiveProgram { this.onTriggerBusyDemo = options.onTriggerBusyDemo; this.onTogglePlaceToPaste = options.onTogglePlaceToPaste; this.onOpenGithub = options.onOpenGithub; + this.onOpenPocket = options.onOpenPocket; } start(): void { @@ -338,6 +342,16 @@ export class TutRunner implements InteractiveProgram { i += 1; continue; } + if ( + this.screen === "flappy" && + this.flappy && + !this.flappy.alive && + (ch === "p" || ch === "P") + ) { + this.onOpenPocket?.(); + i += 1; + continue; + } if (ch === "q" || ch === "Q") { this.handleEscape(); return; @@ -696,7 +710,7 @@ export class TutRunner implements InteractiveProgram { out += moveTo(1, Math.max(2, COLS - bestStr.length - 1)) + fg256(C.dim) + bestStr + RESET; if (!g.alive) { - const r = Math.max(1, Math.floor(ROWS / 2) - 2); + const r = Math.max(1, Math.floor(ROWS / 2) - 3); out += this.flappyCenteredAt(COLS, r, "+--------------------+", fg256(C.red) + BOLD); out += this.flappyCenteredAt(COLS, r + 1, "| GAME OVER |", fg256(C.red) + BOLD); out += this.flappyCenteredAt(COLS, r + 2, "+--------------------+", fg256(C.red) + BOLD); @@ -707,6 +721,12 @@ export class TutRunner implements InteractiveProgram { fg256(C.text), ); out += this.flappyCenteredAt(COLS, r + 6, "[ Enter to restart · Esc to exit ]", fg256(C.dim)); + out += this.flappyCenteredAt( + COLS, + r + 8, + "Checkout dormouse.sh/pocket to play on your phone [p]", + fg256(C.text), + ); } else if (!g.started) { const r = Math.min(ROWS, Math.floor(ROWS / 2) + 2); out += this.flappyCenteredAt(COLS, r, "[ Space / Up to flap · Esc to exit ]", fg256(C.dim)); diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index 7396a85..54439f2 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -56,6 +56,10 @@ function Playground() { ); }, []); + 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; @@ -128,6 +132,7 @@ function Playground() { }, onTogglePlaceToPaste: () => setPlaceToPasteOpen((open) => !open), onOpenGithub: handleOpenGithub, + onOpenPocket: handleOpenPocket, }); } if (name === "ascii-splash" || name === "splash") { diff --git a/website/src/pages/Pocket.tsx b/website/src/pages/Pocket.tsx index 6b72ae6..41ca3a0 100644 --- a/website/src/pages/Pocket.tsx +++ b/website/src/pages/Pocket.tsx @@ -81,6 +81,10 @@ function PocketTerminalExperience({ ); }, []); + 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; @@ -129,6 +133,7 @@ function PocketTerminalExperience({ }, onTogglePlaceToPaste: () => {}, onOpenGithub: handleOpenGithub, + onOpenPocket: handleOpenPocket, }); } if (name === "ascii-splash" || name === "splash") { From 31e7ed386ca76b686f43b85ac32a157df5832b21 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 21 May 2026 22:01:55 -0700 Subject: [PATCH 7/7] Sync tutorial spec to Flappy Term and replace tautological star-prompt assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mystery-row paragraph still described the placeholder Burrow animation (`🧀 ??? 🧀` / `🧀 The Burrow 🧀`), even though the runner now ships the Flappy Term mini-game. Update the spec to match what users actually see, document the `onOpenPocket` wiring and Esc/Enter/Space/p controls, and add the new `dormouse-flappy-high-v1` localStorage key to the Storage section. The star-prompt test asserted the frame did not contain "star the repo" — a string the runner never emits — so it would pass regardless of the star prompt's correctness. Replace with `not.toContain("[not yet]")`, which actually proves the unresolved-state copy is gone after Enter. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/tutorial.md | 3 ++- website/src/lib/tut-runner.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 5c4b00b..b9b0544 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -33,7 +33,7 @@ The runner shows a top-level menu first. Selecting a section drills into its ite 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")`. -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 `🧀 The Burrow 🧀`. Pressing Enter on the unlocked row opens a short runner-local animation; `Esc` returns to the top-level menu. +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")`. Inside a section, items render as one of: @@ -108,6 +108,7 @@ While the Copy paste section is open, pressing `p` toggles the **Place To Paste* - Completion: `localStorage["dormouse-tut-v3"] = JSON.stringify([...completedItemIds])`. Removed on `TutorialState.reset()`. Unknown ids in a stored payload are filtered out on load, so renaming an id is a one-way reset for that item. - GitHub star prompt: `localStorage["dormouse-tut-star-v1"] = "true"` after the user selects `Starred on GitHub`. Removed on `TutorialState.reset()`. +- Flappy Term high score: `localStorage["dormouse-flappy-high-v1"] = String(score)` after each new high score. Removed on `TutorialState.reset()`. - Legacy keys `dormouse-tutorial-step-N` and `dormouse-tut-v2-*` from previous designs are not read; new playground sessions get a fresh start. ## Theme Picker diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 69ce45e..1ef7253 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -104,7 +104,7 @@ describe("TutRunner snapshots", () => { expect(onOpenGithub).toHaveBeenCalledTimes(1); expect(state.isStarPromptResolved()).toBe(true); expect(lastFrame()).toContain("[thanks ⭐]"); - expect(lastFrame()).not.toContain("star the repo"); + expect(lastFrame()).not.toContain("[not yet]"); dispose(); });