diff --git a/packages/pizza-preact/package.json b/packages/pizza-preact/package.json
new file mode 100644
index 0000000..2ede353
--- /dev/null
+++ b/packages/pizza-preact/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@ghostwright/pizza-preact",
+ "version": "0.0.0",
+ "description": "A Preact clack/ui pizza delivery example tested outside-in with Ghostwright",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "start": "tsx src/index.tsx",
+ "test": "vitest run",
+ "typecheck": "tsc -p tsconfig.json"
+ },
+ "dependencies": {
+ "@bomb.sh/tty": "^0.8.0",
+ "@clack/ui": "workspace:*",
+ "@clack/ui-preact": "workspace:*",
+ "@ghostwright/clack-tty": "workspace:*",
+ "preact": "11.0.0-beta.2"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.0",
+ "ghostwright": "workspace:*",
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.2",
+ "vitest": "^4.1.9"
+ },
+ "@clack/ui": {
+ "extensions": [
+ "@ghostwright/clack-tty/auto"
+ ]
+ }
+}
diff --git a/packages/pizza-preact/src/app.tsx b/packages/pizza-preact/src/app.tsx
new file mode 100644
index 0000000..ee3feb0
--- /dev/null
+++ b/packages/pizza-preact/src/app.tsx
@@ -0,0 +1,112 @@
+import { fixed, grow, rgba } from '@bomb.sh/tty';
+import type { ComponentChildren, VNode } from 'preact';
+import { useState } from 'preact/hooks';
+
+const black = rgba(0, 0, 0);
+const blue = rgba(0, 0, 238);
+const cyan = rgba(0, 205, 205);
+const gray = rgba(127, 127, 127);
+
+interface SubmitButtonProps {
+ children: ComponentChildren;
+ label: string;
+}
+
+function SubmitButton({ children, label }: SubmitButtonProps): VNode {
+ return (
+
+ );
+}
+
+interface FieldRowProps {
+ label: string;
+ labelWidth: number;
+}
+
+function FieldRow({ label, labelWidth }: FieldRowProps): VNode {
+ return (
+
+
+ {label}:
+
+
+
+ );
+}
+
+/** Pizza delivery expressed as a Preact tree over the clack/ui Host. */
+export function PizzaDelivery(): VNode {
+ const [cardOpen, setCardOpen] = useState(false);
+
+ return (
+
+
+
+ {cardOpen ? (
+
+ ) : null}
+
+ );
+}
diff --git a/packages/pizza-preact/src/index.tsx b/packages/pizza-preact/src/index.tsx
new file mode 100644
index 0000000..1edce02
--- /dev/null
+++ b/packages/pizza-preact/src/index.tsx
@@ -0,0 +1,9 @@
+import { stdin, stdout } from 'node:process';
+import { createUI } from '@clack/ui';
+import { createRoot } from '@clack/ui-preact';
+import { PizzaDelivery } from './app.tsx';
+
+await using ui = await createUI({ input: stdin, output: stdout });
+const root = createRoot(ui.host.element);
+root.render();
+await ui.main();
diff --git a/packages/pizza-preact/test/pizza-preact.test.ts b/packages/pizza-preact/test/pizza-preact.test.ts
new file mode 100644
index 0000000..7179f86
--- /dev/null
+++ b/packages/pizza-preact/test/pizza-preact.test.ts
@@ -0,0 +1,148 @@
+import { expect, test } from 'vitest';
+import { expectTerminal, withTerminalAsync } from 'ghostwright';
+import {
+ clackTtyExtension,
+ expectFocused,
+ expectTreeCondition,
+ type ClackTtySession,
+} from '@ghostwright/clack-tty';
+
+// The Preact application is a process-level black box. This test drives the
+// real terminal and observes only its visible screen and semantic tree.
+const extension = clackTtyExtension();
+
+const entry = () => ({
+ command: process.execPath,
+ args: ['--import', 'tsx', 'src/index.tsx'],
+ cwd: new URL('..', import.meta.url).pathname,
+ viewport: { columns: 80, rows: 24 },
+ env: { CLACK_UI_SEMANTIC: '1' },
+ trace: 'off' as const,
+ extensions: [extension],
+});
+
+type Terminal = Parameters[1]>[0];
+
+function semantic(terminal: Terminal) {
+ return terminal.extension(extension) as ClackTtySession;
+}
+
+async function tabTo(terminal: Terminal, session: ClackTtySession, expectedLabel: string) {
+ const previousLabel = session.locator('[focused]').matches()[0]?.attrs.label;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await terminal.keyboard.press('Tab');
+ try {
+ await expectTreeCondition(
+ terminal,
+ () => session.locator('[focused]').matches()[0]?.attrs.label !== previousLabel,
+ `focus leaves ${previousLabel}`,
+ 1200,
+ );
+ } catch {
+ if (attempt < 2) continue;
+ throw new Error(`focus did not leave ${previousLabel}`);
+ }
+
+ const actualLabel = session.locator('[focused]').matches()[0]?.attrs.label;
+ expect(actualLabel).toBe(expectedLabel);
+ return;
+ }
+}
+
+test('Preact pizza completes both forms and restores the delivery tab order', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+
+ const name = session.locator('input[label="name"]');
+ const address = session.locator('input[label="address"]');
+ const addCard = session.locator('button[label="add-card"]');
+ const cardNumber = session.locator('input[label="card-number"]');
+ const expiry = session.locator('input[label="expiry"]');
+ const cvc = session.locator('input[label="cvc"]');
+ const submitCard = session.locator('button[label="submit-card"]');
+ const dialog = session.locator('dialog[role="dialog"][label="card"]');
+
+ await expectFocused(terminal, name);
+ await terminal.keyboard.type('Ryan');
+ await expectTerminal(name.getByText('Ryan')).toBePresent();
+ await tabTo(terminal, session, 'address');
+ await terminal.keyboard.type('1 Main St');
+ await expectTerminal(address.getByText('1 Main St')).toBePresent();
+ await tabTo(terminal, session, 'add-card');
+ await expectFocused(terminal, addCard);
+ await terminal.keyboard.press('Enter');
+
+ await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens');
+ await expectFocused(terminal, cardNumber);
+ await tabTo(terminal, session, 'expiry');
+ await expectFocused(terminal, expiry);
+ await tabTo(terminal, session, 'cvc');
+ await expectFocused(terminal, cvc);
+ await tabTo(terminal, session, 'submit-card');
+ await expectFocused(terminal, submitCard);
+ await terminal.keyboard.press('Enter');
+
+ await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes');
+ await expectFocused(terminal, addCard);
+ await expectTerminal(name.getByText('Ryan')).toBePresent();
+ await expectTerminal(address.getByText('1 Main St')).toBePresent();
+ await tabTo(terminal, session, 'name');
+ await expectFocused(terminal, name);
+ });
+});
+
+test('focused inputs show a native cursor that follows the caret', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+
+ const name = session.locator('input[label="name"]');
+ const address = session.locator('input[label="address"]');
+ const addCard = session.locator('button[label="add-card"]');
+ const cursorIsInside = (selector: string) => {
+ const cursor = terminal.screen.snapshot().cursor;
+ const rect = session.locator(selector).matches()[0]?.geo?.term;
+ return (
+ cursor.visible &&
+ rect !== undefined &&
+ cursor.column > rect.column &&
+ cursor.column < rect.column + rect.width - 1 &&
+ cursor.row > rect.row &&
+ cursor.row < rect.row + rect.height - 1
+ );
+ };
+
+ await expectFocused(terminal, name);
+ const initial = await expectTerminal(terminal).toSatisfy(
+ () => cursorIsInside('input[label="name"]'),
+ { settleMs: 100 },
+ );
+
+ await terminal.keyboard.type('cat');
+ const typed = await expectTerminal(terminal).toSatisfy(
+ () => terminal.screen.snapshot().cursor.column === initial.cursor.column + 3,
+ { settleMs: 100 },
+ );
+
+ await terminal.keyboard.press('ArrowLeft');
+ await expectTerminal(terminal).toSatisfy(
+ () => terminal.screen.snapshot().cursor.column === typed.cursor.column - 1,
+ { settleMs: 100 },
+ );
+
+ await terminal.keyboard.press('Tab');
+ await expectFocused(terminal, address);
+ await expectTerminal(terminal).toSatisfy(
+ () => cursorIsInside('input[label="address"]'),
+ { settleMs: 100 },
+ );
+
+ await terminal.keyboard.press('Tab');
+ await expectFocused(terminal, addCard);
+ await expectTerminal(terminal).toSatisfy(
+ () => !terminal.screen.snapshot().cursor.visible,
+ { settleMs: 100 },
+ );
+ });
+});
diff --git a/packages/pizza-preact/tsconfig.json b/packages/pizza-preact/tsconfig.json
new file mode 100644
index 0000000..ff65e5c
--- /dev/null
+++ b/packages/pizza-preact/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "@bomb.sh/tools/tsconfig.json",
+ "compilerOptions": {
+ "composite": false,
+ "declaration": false,
+ "declarationMap": false,
+ "jsx": "react-jsx",
+ "jsxImportSource": "@clack/ui-preact",
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/packages/pizza-preact/vitest.config.ts b/packages/pizza-preact/vitest.config.ts
new file mode 100644
index 0000000..da41b43
--- /dev/null
+++ b/packages/pizza-preact/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ testTimeout: 60_000,
+ hookTimeout: 30_000,
+ teardownTimeout: 30_000,
+ pool: 'forks',
+ },
+});
diff --git a/packages/pizza/package.json b/packages/pizza/package.json
new file mode 100644
index 0000000..72edf99
--- /dev/null
+++ b/packages/pizza/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@ghostwright/pizza",
+ "version": "0.0.0",
+ "description": "A clack/ui pizza delivery form with a card dialog, validated with ghostwright tree locators",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "start": "tsx src/pizza.ts",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@bomb.sh/tty": "^0.8.0",
+ "@clack/ui": "workspace:*",
+ "@ghostwright/clack-tty": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.0",
+ "ghostwright": "workspace:*",
+ "tsx": "^4.19.0",
+ "vitest": "^4.1.9"
+ },
+ "@clack/ui": {
+ "extensions": ["@ghostwright/clack-tty/auto"]
+ },
+ "devEngines": {
+ "packageManager": {
+ "name": "pnpm",
+ "version": "10.7.0",
+ "onFail": "error"
+ },
+ "runtime": {
+ "name": "node",
+ "version": "22.14.0",
+ "onFail": "error"
+ }
+ }
+}
diff --git a/packages/pizza/src/pizza.ts b/packages/pizza/src/pizza.ts
new file mode 100644
index 0000000..86efecd
--- /dev/null
+++ b/packages/pizza/src/pizza.ts
@@ -0,0 +1,192 @@
+/**
+ * Pizza delivery: a clack/ui form application. A delivery form submits on
+ * Enter, which opens the card dialog; the card form submits on Enter, which
+ * closes it. No keyboard policy lives in this file — forms own implicit
+ * submission, the way they do in the DOM.
+ *
+ * Run: `tsx src/pizza.ts`
+ */
+import { stdin, stdout } from 'node:process';
+import { fixed, grow, rgba } from '@bomb.sh/tty';
+import { createUI, type HostElement } from '@clack/ui';
+
+const black = rgba(0, 0, 0);
+const blue = rgba(0, 0, 238);
+const cyan = rgba(0, 205, 205);
+const gray = rgba(127, 127, 127);
+
+const ui = await createUI({ input: stdin, output: stdout });
+const { host } = ui;
+
+// Give the application one explicit, full-screen layout parent. Floating
+// children can then attach to this stable surface as the terminal resizes.
+const screen = host.createElement('box');
+host.setProperty(screen, 'layout', {
+ direction: 'ttb',
+ width: grow(),
+ height: grow(),
+});
+
+function button(labelText: string, name: string): HostElement {
+ const element = host.createElement('button');
+ host.setProperty(element, 'role', 'button');
+ host.setProperty(element, 'label', name);
+ host.setProperty(element, 'type', 'submit');
+ host.setProperty(element, 'layout', {
+ width: fixed(16),
+ height: fixed(3),
+ padding: { top: 1, right: 1, bottom: 1, left: 1 },
+ });
+ host.setProperty(element, 'border', {
+ color: gray,
+ top: 1,
+ right: 1,
+ bottom: 1,
+ left: 1,
+ });
+ host.insertBefore(element, host.createLiteral(labelText));
+ return element;
+}
+
+function field(name: string): HostElement {
+ const element = host.createElement('input');
+ host.setProperty(element, 'role', 'textbox');
+ host.setProperty(element, 'label', name);
+ return element;
+}
+
+function submitNote(content: string): HostElement {
+ const element = host.createElement('text');
+ host.setProperty(element, 'color', gray);
+ host.insertBefore(element, host.createLiteral(content));
+ return element;
+}
+
+// --- delivery form ---------------------------------------------------------
+
+const nameInput = field('name');
+const addressInput = field('address');
+
+const delivery = host.createElement('form');
+host.setProperty(delivery, 'role', 'form');
+host.setProperty(delivery, 'label', 'delivery');
+host.setProperty(delivery, 'layout', {
+ direction: 'ttb',
+ gap: 1,
+ padding: { top: 1, bottom: 1, left: 2, right: 2 },
+ width: grow(32, 44),
+});
+host.setProperty(delivery, 'border', { color: blue, top: 1, right: 1, bottom: 1, left: 1 });
+
+const header = host.createElement('text');
+host.setProperty(header, 'color', cyan);
+host.insertBefore(header, host.createLiteral('Pizza Delivery'));
+host.insertBefore(delivery, header);
+
+for (const [labelText, element] of [
+ ['name:', nameInput],
+ ['address:', addressInput],
+] as const) {
+ const row = host.createElement('box');
+ host.setProperty(row, 'layout', { direction: 'ltr', gap: 1, width: grow() });
+ const label = host.createElement('box');
+ host.setProperty(label, 'layout', { width: fixed(9) });
+ const label2 = host.createElement('text');
+ host.setProperty(label2, 'color', gray);
+ host.insertBefore(label2, host.createLiteral(labelText));
+ host.insertBefore(label, label2);
+ host.insertBefore(row, label);
+ host.insertBefore(row, element);
+ host.insertBefore(delivery, row);
+}
+const actions = host.createElement('box');
+host.setProperty(actions, 'layout', { direction: 'ltr', gap: 1, width: grow() });
+host.insertBefore(actions, button('Add card', 'add-card'));
+host.insertBefore(delivery, actions);
+
+// --- card dialog -----------------------------------------------------------
+
+const cardNumberInput = field('card-number');
+const expiryInput = field('expiry');
+const cvcInput = field('cvc');
+
+const cardDialog = host.createElement('dialog');
+host.setProperty(cardDialog, 'role', 'dialog');
+host.setProperty(cardDialog, 'label', 'card');
+host.setProperty(cardDialog, 'modal', true);
+host.setProperty(cardDialog, 'layout', {
+ direction: 'ttb',
+ width: grow(32, 44),
+});
+host.setProperty(cardDialog, 'bg', black);
+host.setProperty(cardDialog, 'border', {
+ color: blue,
+ top: 1,
+ right: 1,
+ bottom: 1,
+ left: 1,
+});
+host.setProperty(cardDialog, 'floating', {
+ attachTo: 'parent',
+ attachPoints: { element: 'center-center', parent: 'center-center' },
+ zIndex: 1,
+});
+
+const card = host.createElement('form');
+host.setProperty(card, 'role', 'form');
+host.setProperty(card, 'label', 'card-payment');
+host.setProperty(card, 'layout', {
+ direction: 'ttb',
+ gap: 1,
+ padding: { top: 1, bottom: 1, left: 2, right: 2 },
+ width: grow(),
+});
+host.insertBefore(cardDialog, card);
+
+const cardHeader = host.createElement('text');
+host.setProperty(cardHeader, 'color', cyan);
+host.insertBefore(cardHeader, host.createLiteral('Card Details'));
+host.insertBefore(card, cardHeader);
+
+for (const [labelText, element] of [
+ ['card-number:', cardNumberInput],
+ ['expiry:', expiryInput],
+ ['cvc:', cvcInput],
+] as const) {
+ const row = host.createElement('box');
+ host.setProperty(row, 'layout', { direction: 'ltr', gap: 1, width: grow() });
+ const label = host.createElement('box');
+ host.setProperty(label, 'layout', { width: fixed(13) });
+ const label2 = host.createElement('text');
+ host.setProperty(label2, 'color', gray);
+ host.insertBefore(label2, host.createLiteral(labelText));
+ host.insertBefore(label, label2);
+ host.insertBefore(row, label);
+ host.insertBefore(row, element);
+ host.insertBefore(card, row);
+}
+const cardActions = host.createElement('box');
+host.setProperty(cardActions, 'layout', { direction: 'ltr', gap: 1, width: grow() });
+host.insertBefore(cardActions, button('Submit card', 'submit-card'));
+host.insertBefore(card, cardActions);
+
+// --- behavior: forms submit, the app decides what that means ---------------
+
+let cardOpen = false;
+
+host.addEventListener(delivery, 'submit', () => {
+ if (cardOpen) return;
+ cardOpen = true;
+ host.insertBefore(screen, cardDialog);
+});
+
+host.addEventListener(card, 'submit', () => {
+ if (!cardOpen) return;
+ cardOpen = false;
+ host.removeChild(screen, cardDialog);
+});
+
+host.insertBefore(screen, delivery);
+host.insertBefore(host.element, screen);
+
+await ui.main();
diff --git a/packages/pizza/test/pizza.test.ts b/packages/pizza/test/pizza.test.ts
new file mode 100644
index 0000000..0309498
--- /dev/null
+++ b/packages/pizza/test/pizza.test.ts
@@ -0,0 +1,282 @@
+import { expect, test } from 'vitest';
+import { expectTerminal, withTerminalAsync } from 'ghostwright';
+import {
+ clackTtyExtension,
+ expectFocused,
+ expectTreeCondition,
+ type ClackTtySession,
+} from '@ghostwright/clack-tty';
+
+// Outside-in acceptance suite: the pizza application is a black box. The tests
+// drive it through the real terminal (ghostwright PTY) and observe only the
+// visible screen and the semantic tree it emits. No implementation knowledge.
+const extension = clackTtyExtension();
+
+const entry = () => ({
+ command: process.execPath,
+ args: ['--import', 'tsx', 'src/pizza.ts'],
+ cwd: new URL('..', import.meta.url).pathname,
+ viewport: { columns: 80, rows: 24 },
+ env: { CLACK_UI_SEMANTIC: '1' },
+ trace: 'off' as const,
+ extensions: [extension],
+});
+
+type Terminal = Parameters[1]>[0];
+
+function semantic(terminal: Terminal) {
+ return terminal.extension(extension) as ClackTtySession;
+}
+
+async function tabTo(terminal: Terminal, session: ClackTtySession, expectedLabel: string) {
+ const previousLabel = session.locator('[focused]').matches()[0]?.attrs.label;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await terminal.keyboard.press('Tab');
+ try {
+ await expectTreeCondition(
+ terminal,
+ () => session.locator('[focused]').matches()[0]?.attrs.label !== previousLabel,
+ `focus leaves ${previousLabel}`,
+ 1200,
+ );
+ } catch {
+ if (attempt < 2) continue;
+ throw new Error(`focus did not leave ${previousLabel}`);
+ }
+
+ const actualLabel = session.locator('[focused]').matches()[0]?.attrs.label;
+ expect(actualLabel).toBe(expectedLabel);
+ return;
+ }
+}
+
+test('renders the delivery form and focuses the first field', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ // visible screen
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+
+ // semantic tree: a delivery form with name and address fields
+ const form = semantic(terminal).locator('form[label="delivery"]');
+ await expectTreeCondition(terminal, () => form.matches().length === 1, 'form in tree');
+ expect(semantic(terminal).locator('input[label="name"]').matches()).toHaveLength(1);
+ expect(semantic(terminal).locator('input[label="address"]').matches()).toHaveLength(1);
+
+ // focus starts on the first field
+ await expectFocused(terminal, semantic(terminal).locator('input[label="name"]'));
+ });
+});
+
+test('reflows forms when the terminal resizes', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+
+ await terminal.resize({ columns: 36, rows: 20 });
+
+ const fitsSurface = (selector: string) => {
+ const frame = session.current();
+ const geometry = session.locator(selector).matches()[0]?.geo;
+ const term = geometry?.term;
+ const visible = geometry?.visible;
+ return (
+ frame?.surface.columns === 36 &&
+ frame.surface.rows === 20 &&
+ term !== undefined &&
+ visible !== undefined &&
+ term.column >= 0 &&
+ term.row >= 0 &&
+ term.column + term.width <= frame.surface.columns &&
+ term.row + term.height <= frame.surface.rows &&
+ visible.column === term.column &&
+ visible.row === term.row &&
+ visible.width === term.width &&
+ visible.height === term.height
+ );
+ };
+
+ await expectTreeCondition(
+ terminal,
+ () => fitsSurface('form[label="delivery"]'),
+ 'delivery form fits resized surface',
+ );
+
+ await terminal.keyboard.press('Enter');
+ await expectTreeCondition(
+ terminal,
+ () => fitsSurface('dialog[role="dialog"][label="card"]'),
+ 'card dialog fits resized surface',
+ );
+ });
+});
+
+test('Tab cycles the delivery fields and wraps', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+ const name = session.locator('input[label="name"]');
+ const address = session.locator('input[label="address"]');
+
+ const addCard = session.locator('button[label="add-card"]');
+ await expectFocused(terminal, name);
+ await terminal.keyboard.press('Tab');
+ await expectFocused(terminal, address);
+ await terminal.keyboard.press('Tab');
+ await expectFocused(terminal, addCard);
+ // the dialog is closed, so the cycle wraps back to the first field
+ await terminal.keyboard.press('Tab');
+ await expectFocused(terminal, name);
+ });
+});
+
+test('typing updates the field value on screen and in the tree', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+ const name = session.locator('input[label="name"]');
+ const address = session.locator('input[label="address"]');
+
+ await terminal.keyboard.type('Ryan');
+ await expectTerminal(name.getByText('Ryan')).toBePresent();
+
+ await terminal.keyboard.press('Tab');
+ await terminal.keyboard.type('1 Main St');
+ await expectTerminal(address.getByText('1 Main St')).toBePresent();
+
+ // the greeting-style header is untouched
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+ });
+});
+
+test('Enter opens the card dialog and focuses the card number', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+
+ await terminal.keyboard.press('Enter');
+
+ const dialog = session.locator('dialog[role="dialog"][label="card"]');
+ await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens');
+ expect(session.locator('input[label="card-number"]').matches()).toHaveLength(1);
+ expect(session.locator('input[label="expiry"]').matches()).toHaveLength(1);
+ expect(session.locator('input[label="cvc"]').matches()).toHaveLength(1);
+ await expectFocused(terminal, session.locator('input[label="card-number"]'));
+
+ // the delivery form stays mounted with its values
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+ });
+});
+
+test('the card journey: type through the dialog fields', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+ await terminal.keyboard.press('Enter');
+ const dialog = session.locator('dialog[role="dialog"][label="card"]');
+ await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens');
+
+ const cardNumber = session.locator('input[label="card-number"]');
+ const expiry = session.locator('input[label="expiry"]');
+ const cvc = session.locator('input[label="cvc"]');
+
+ await terminal.keyboard.type('4111111');
+ await expectTerminal(cardNumber.getByText('4111111')).toBePresent();
+
+ await terminal.keyboard.press('Tab');
+ await expectFocused(terminal, expiry);
+ await terminal.keyboard.type('12/26');
+ await expectTerminal(expiry.getByText('12/26')).toBePresent();
+
+ await terminal.keyboard.press('Tab');
+ await expectFocused(terminal, cvc);
+ await terminal.keyboard.type('123');
+ await expectTerminal(cvc.getByText('123')).toBePresent();
+ });
+});
+
+test('Enter closes the dialog, keeps form values, and restores focus', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+ const name = session.locator('input[label="name"]');
+ const dialog = session.locator('dialog[role="dialog"][label="card"]');
+
+ // build state: name typed, dialog opened
+ await terminal.keyboard.type('Ryan');
+ await expectTerminal(name.getByText('Ryan')).toBePresent();
+ await terminal.keyboard.press('Enter');
+ await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens');
+
+ // close: Enter on a focused card field
+ await terminal.keyboard.press('Enter');
+ await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes');
+ expect(session.locator('input[label="card-number"]').matches()).toHaveLength(0);
+
+ // form values survive the dialog round trip
+ await expectTerminal(name.getByText('Ryan')).toBePresent();
+
+ // focus returns to the control that opened the modal
+ await expectFocused(terminal, name);
+ });
+});
+
+test('button submission restores the delivery tab order', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+
+ const name = session.locator('input[label="name"]');
+ const address = session.locator('input[label="address"]');
+ const addCard = session.locator('button[label="add-card"]');
+ const cardNumber = session.locator('input[label="card-number"]');
+ const expiry = session.locator('input[label="expiry"]');
+ const cvc = session.locator('input[label="cvc"]');
+ const submitCard = session.locator('button[label="submit-card"]');
+ const dialog = session.locator('dialog[role="dialog"][label="card"]');
+
+ await expectFocused(terminal, name);
+ await tabTo(terminal, session, 'address');
+ await expectFocused(terminal, address);
+ await tabTo(terminal, session, 'add-card');
+ await expectFocused(terminal, addCard);
+ await terminal.keyboard.press('Enter');
+
+ await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens');
+ await expectFocused(terminal, cardNumber);
+ await tabTo(terminal, session, 'expiry');
+ await expectFocused(terminal, expiry);
+ await tabTo(terminal, session, 'cvc');
+ await expectFocused(terminal, cvc);
+ await tabTo(terminal, session, 'submit-card');
+ await expectFocused(terminal, submitCard);
+ await terminal.keyboard.press('Enter');
+
+ await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes');
+ await expectFocused(terminal, addCard);
+ await tabTo(terminal, session, 'name');
+ await expectFocused(terminal, name);
+ });
+});
+
+test('with the dialog open, Tab is contained by the modal', async () => {
+ await withTerminalAsync(entry(), async (terminal) => {
+ const session = semantic(terminal);
+ await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable();
+ const dialog = session.locator('dialog[role="dialog"][label="card"]');
+ const order = ['expiry', 'cvc', 'submit-card', 'card-number'];
+
+ await terminal.keyboard.press('Enter');
+ await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens');
+
+ // the app focuses card-number when the dialog opens; walk the full cycle
+ const labels: (string | undefined)[] = [];
+ await expectFocused(terminal, session.locator('input[label="card-number"]'));
+ for (const label of order) {
+ await tabTo(terminal, session, label);
+ labels.push(session.locator('[focused]').matches()[0]?.attrs.label);
+ }
+ expect(labels).toEqual(order);
+
+ await terminal.keyboard.press('Shift+Tab');
+ await expectFocused(terminal, session.locator('button[label="submit-card"]'));
+ });
+});
diff --git a/packages/pizza/vitest.config.ts b/packages/pizza/vitest.config.ts
new file mode 100644
index 0000000..48cdfd7
--- /dev/null
+++ b/packages/pizza/vitest.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ testTimeout: 60_000,
+ hookTimeout: 30_000,
+ teardownTimeout: 30_000,
+ pool: 'forks',
+ poolOptions: { forks: { singleFork: true } },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 685c731..dfda6ea 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -31,7 +31,7 @@ importers:
version: 0.3.1
'@bomb.sh/tools':
specifier: ^0.6.1
- version: 0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
+ version: 0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
'@clack/prompts':
specifier: 'catalog:'
version: 1.7.0
@@ -119,6 +119,65 @@ importers:
specifier: ^4.1.9
version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
+ packages/pizza:
+ dependencies:
+ '@bomb.sh/tty':
+ specifier: https://pkg.pr.new/@bomb.sh/tty@103
+ version: https://pkg.pr.new/@bomb.sh/tty@103
+ '@clack/ui':
+ specifier: workspace:*
+ version: link:../../vendor/clack-ui
+ '@ghostwright/clack-tty':
+ specifier: workspace:*
+ version: link:../clack-tty
+ devDependencies:
+ '@types/node':
+ specifier: ^22.20.0
+ version: 22.20.1
+ ghostwright:
+ specifier: workspace:*
+ version: link:../../experiments/ghostwright
+ tsx:
+ specifier: ^4.19.0
+ version: 4.23.13
+ vitest:
+ specifier: ^4.1.9
+ version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
+
+ packages/pizza-preact:
+ dependencies:
+ '@bomb.sh/tty':
+ specifier: https://pkg.pr.new/@bomb.sh/tty@103
+ version: https://pkg.pr.new/@bomb.sh/tty@103
+ '@clack/ui':
+ specifier: workspace:*
+ version: link:../../vendor/clack-ui
+ '@clack/ui-preact':
+ specifier: workspace:*
+ version: link:../../vendor/clack-ui-preact
+ '@ghostwright/clack-tty':
+ specifier: workspace:*
+ version: link:../clack-tty
+ preact:
+ specifier: 11.0.0-beta.2
+ version: 11.0.0-beta.2
+ devDependencies:
+ '@types/node':
+ specifier: ^22.20.0
+ version: 22.20.1
+ ghostwright:
+ specifier: workspace:*
+ version: link:../../experiments/ghostwright
+ tsx:
+ specifier: ^4.19.0
+ version: 4.23.13
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vitest:
+ specifier: ^4.1.9
+ version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
+
vendor/clack-ui:
dependencies:
'@bomb.sh/tty':
@@ -149,7 +208,7 @@ importers:
devDependencies:
'@bomb.sh/tools':
specifier: ^0.5.4
- version: 0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
+ version: 0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
@@ -1663,6 +1722,11 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
ultramatter@0.0.4:
resolution: {integrity: sha512-1f/hO3mR+/Hgue4eInOF/Qm/wzDqwhYha4DxM0hre9YIUyso3fE2XtrAU6B4njLqTC8CM49EZaYgsVSa+dXHGw==}
@@ -1805,7 +1869,7 @@ snapshots:
'@bomb.sh/args@0.3.1': {}
- '@bomb.sh/tools@0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))':
+ '@bomb.sh/tools@0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))':
dependencies:
'@bomb.sh/args': 0.3.1
'@humanfs/node': 0.16.8
@@ -1816,7 +1880,7 @@ snapshots:
oxlint: 1.74.0
publint: 0.3.21
tinyexec: 1.2.4
- tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(unrun@0.2.39)
+ tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39)
ultramatter: 0.0.4
vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
vitest-ansi-serializer: 0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))
@@ -1848,7 +1912,7 @@ snapshots:
- vite-plus
- vue-tsc
- '@bomb.sh/tools@0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))':
+ '@bomb.sh/tools@0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))':
dependencies:
'@bomb.sh/args': 0.3.1
'@humanfs/node': 0.16.8
@@ -1859,7 +1923,7 @@ snapshots:
oxlint: 1.74.0
publint: 0.3.21
tinyexec: 1.2.4
- tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(unrun@0.2.39)
+ tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39)
ultramatter: 0.0.4
vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
vitest-ansi-serializer: 0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))
@@ -2916,7 +2980,7 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
- rolldown-plugin-dts@0.27.9(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.5):
+ rolldown-plugin-dts@0.27.9(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.5)(typescript@5.9.3):
dependencies:
dts-resolver: 3.0.0(oxc-resolver@11.21.3)
get-tsconfig: 5.0.0-beta.5
@@ -2927,6 +2991,7 @@ snapshots:
yuku-parser: 0.6.3
optionalDependencies:
'@typescript/native-preview': 7.0.0-dev.20260623.1
+ typescript: 5.9.3
transitivePeerDependencies:
- oxc-resolver
@@ -3008,7 +3073,7 @@ snapshots:
tree-kill@1.2.2: {}
- tsdown@0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(unrun@0.2.39):
+ tsdown@0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39):
dependencies:
ansis: 4.3.1
cac: 7.0.0
@@ -3019,7 +3084,7 @@ snapshots:
obug: 2.1.3
picomatch: 4.0.5
rolldown: 1.1.5
- rolldown-plugin-dts: 0.27.9(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.5)
+ rolldown-plugin-dts: 0.27.9(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.5)(typescript@5.9.3)
semver: 7.8.5
tinyexec: 1.2.4
tinyglobby: 0.2.17
@@ -3028,6 +3093,7 @@ snapshots:
optionalDependencies:
publint: 0.3.21
tsx: 4.23.13
+ typescript: 5.9.3
unrun: 0.2.39
transitivePeerDependencies:
- '@ts-macro/tsc'
@@ -3044,6 +3110,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ typescript@5.9.3: {}
+
ultramatter@0.0.4: {}
unbash@4.0.1: {}