docs: complete LAN presentation sync

This commit is contained in:
lda
2026-07-14 15:22:07 +07:00 Verified
parent 17da3e5d6c
commit b87a6abcde
10 changed files with 1261 additions and 9 deletions
+42 -4
View File
@@ -38,6 +38,35 @@ pnpm --dir web start
A single Hono process serves the built React application and API routes from
`http://127.0.0.1:8787`.
### LAN Presentation Rehearsal
Build once, then bind the production Hono server to the laptop's LAN
interfaces:
```powershell
pnpm --dir web build
$env:WEB_HOST = "0.0.0.0"
pnpm --dir web start
```
Open `http://<laptop-lan-ip>:8787/presenter` on the phone and
`http://<laptop-lan-ip>:8787/present` on the audience display. Either route can
**Start session** or **Join session**. Starting shows a six-character code, QR
target, and opposite-route join link; the other device can scan the QR, open
the link, or enter the code. Navigation is bidirectional, and the presenter
route can end the session for every paired device.
Rooms are short-lived and in memory. After a temporary disconnect or reload,
the client reconnects and applies the latest server snapshot; that snapshot
wins over navigation performed locally while offline. A server restart or room
expiry requires a new session.
This mode is for a **trusted LAN only**. The join code is not authentication,
the built server does not provide TLS, and this runbook makes no
public-internet security claim. Keep `wf-rpc-server` on loopback at port `8765`:
browser workflow operations continue through the Hono boundary on port `8787`,
so the workflow RPC server does not need a LAN binding.
## Commands
| Command | Description |
@@ -48,6 +77,12 @@ A single Hono process serves the built React application and API routes from
| `pnpm --dir web typecheck` | Run TypeScript type checking |
| `pnpm --dir web build` | Build the React console for production |
| `pnpm --dir web start` | Start the production Hono server |
| `pnpm --dir web --filter @lda/console test:presentation-sync:e2e:install` | Install the Chromium binary required by the browser smoke test |
| `pnpm --dir web --filter @lda/console test:presentation-sync:e2e` | Run isolated two-context presentation synchronization smoke tests against a built server |
Run the E2E install command once after `pnpm install` on each clean development
or CI machine. Playwright keeps the matching Chromium binary in its browser
cache for subsequent smoke runs.
## Architecture
@@ -58,6 +93,7 @@ web/
server/ Hono local server (API + static serving)
packages/
rpc/ Effect-based JSON-RPC client, schemas, and errors
presentation-sync/ Bounded presentation room wire contract
```
The browser communicates with Hono at `/api/connect` and `/api/rpc`. Hono
@@ -174,12 +210,14 @@ The console exposes `/present`, a 720p no-scroll defense compositor for the
stage regions, discussion branches, one editorial canvas, persistent scene-aware
assistant surfaces, and keyboard navigation.
The companion `/presenter` route is a read-only speech and Q&A reader. It uses
The companion `/presenter` route is a speech and Q&A reader. It uses
the same `#scene/<scene>/<beat>` and `#discuss/<branch>` hashes, shows target and
cumulative timing, keeps optional detail/evidence/Q&A collapsed, and links to
the corresponding audience slide in a new tab. It performs no workflow RPC,
replay, live-target, or cross-window synchronization. Use ArrowLeft and
ArrowRight to move between notes; covered checkboxes remain local to the page.
replay, or live-target operations. Its shared pairing controller synchronizes
canonical navigation hashes with `/present` through Hono without duplicating
storyboard semantics. Use ArrowLeft and ArrowRight to move between notes;
covered checkboxes remain local to the page.
Must-say notes support authored inline Markdown emphasis for rapid scanning, and
the stable Previous/Next bar remains available at narrow viewport widths.
@@ -331,7 +369,7 @@ discussion transitions.
This plan deliberately defers:
- AI Elements / Vercel AI chat primitives
- Live LLM driver integration
- Remote phone control
- Public-internet presentation hosting, TLS, and authentication
- Final visual polish and motion choreography
### Constrained Demo Agent
@@ -0,0 +1,222 @@
import { createServer } from "node:net";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test";
const INITIAL_HASH = "#scene/thesis/title";
const ARCHITECTURE_FOCUS_HASH =
"#scene/architecture/runtime/focus/runtime-providers/configured-providers";
const DISCUSSION_HASH = "#discuss/where-is-ai-agent";
const serverEntry = fileURLToPath(new URL("../../server/dist/index.js", import.meta.url));
let server: ChildProcessWithoutNullStreams;
let baseUrl: string;
const reservePort = async (): Promise<number> => {
const listener = createServer();
listener.listen(0, "127.0.0.1");
await once(listener, "listening");
const address = listener.address();
if (address === null || typeof address === "string") {
listener.close();
throw new Error("Could not reserve an E2E server port");
}
const { port } = address;
listener.close();
await once(listener, "close");
return port;
};
const waitForServer = async (child: ChildProcessWithoutNullStreams): Promise<void> => {
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) {
throw new Error(`E2E server exited with ${child.exitCode}:\n${output}`);
}
try {
const response = await fetch(baseUrl);
if (response.ok) return;
} catch {
// The isolated process has not bound its port yet.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`E2E server did not become ready:\n${output}`);
};
const stopServer = async (): Promise<void> => {
if (server.exitCode !== null) return;
const exited = once(server, "exit");
server.kill("SIGTERM");
const exitedGracefully = await Promise.race([
exited.then(() => true),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 7_000)),
]);
if (exitedGracefully || server.exitCode !== null) return;
// Never reach beyond the child created by this test when graceful server
// shutdown fails; force only that recorded process and wait for its exit.
const forcedExit = once(server, "exit");
server.kill("SIGKILL");
await forcedExit;
};
const hashOf = (page: Page): string => new URL(page.url()).hash;
const expectHashes = async (expected: string, ...pages: readonly Page[]): Promise<void> => {
await expect.poll(() => pages.map(hashOf)).toEqual(pages.map(() => expected));
};
const startSession = async (
page: Page,
creatorPath: "/present" | "/presenter",
): Promise<string> => {
await page.getByRole("button", { name: "Pair presentation" }).click();
await page.getByRole("button", { name: "Start session" }).click();
const code = page.locator(".presentation-pairing__code");
await expect(code).toBeVisible();
const value = (await code.textContent())?.trim() ?? "";
const joinPath = creatorPath === "/presenter" ? "/present" : "/presenter";
const joinUrl = `${baseUrl}${joinPath}?pair=${value}`;
await expect(page.getByRole("img", { name: "Pairing QR code" })).toHaveAttribute(
"data-qr-value",
joinUrl,
);
await expect(page.getByRole("link", { name: "Copyable join URL" })).toHaveAttribute(
"href",
joinUrl,
);
return value;
};
const expectConnected = async (...pages: readonly Page[]): Promise<void> => {
await Promise.all(
pages.map((page) =>
expect(page.getByRole("status", { name: "Connected" })).toBeVisible(),
),
);
};
const openPair = async (
browser: Browser,
creatorPath: "/present" | "/presenter",
joinMethod: "link" | "code" = "link",
): Promise<{
creatorContext: BrowserContext;
creator: Page;
joinerContext: BrowserContext;
joiner: Page;
}> => {
const creatorIsPresenter = creatorPath === "/presenter";
const creatorContext = await browser.newContext({
viewport: creatorIsPresenter ? { width: 390, height: 844 } : { width: 1280, height: 720 },
});
const joinerContext = await browser.newContext({
viewport: creatorIsPresenter ? { width: 1280, height: 720 } : { width: 390, height: 844 },
});
const creator = await creatorContext.newPage();
await creator.goto(`${baseUrl}${creatorPath}${INITIAL_HASH}`);
const code = await startSession(creator, creatorPath);
expect(code).toMatch(/^[A-Z0-9]{6}$/);
const joinerPath = creatorIsPresenter ? "/present" : "/presenter";
const joiner = await joinerContext.newPage();
if (joinMethod === "link") {
await joiner.goto(`${baseUrl}${joinerPath}?pair=${code}${INITIAL_HASH}`);
} else {
await joiner.goto(`${baseUrl}${joinerPath}${INITIAL_HASH}`);
await joiner.getByRole("button", { name: "Pair presentation" }).click();
await joiner.getByLabel("Pairing code").fill(code);
await joiner.getByRole("button", { name: "Join session" }).click();
}
await expectConnected(creator, joiner);
await expectHashes(INITIAL_HASH, creator, joiner);
return { creatorContext, creator, joinerContext, joiner };
};
test.beforeAll(async () => {
const port = await reservePort();
baseUrl = `http://127.0.0.1:${port}`;
server = spawn(process.execPath, [serverEntry], {
env: {
...process.env,
WEB_HOST: process.env.PRESENTATION_E2E_BIND_HOST ?? "127.0.0.1",
WEB_PORT: String(port),
},
stdio: ["pipe", "pipe", "pipe"],
});
await waitForServer(server);
});
test.afterAll(async () => {
await stopServer();
});
test("synchronizes presenter and audience navigation, reload, fidelity, and termination", async ({ browser }) => {
const { creatorContext: phoneContext, creator: phone, joinerContext: audienceContext, joiner: audience } =
await openPair(browser, "/presenter");
try {
await phone.getByRole("link", { name: "Next →", exact: true }).click();
await expectHashes("#scene/thesis/substrate", phone, audience);
await audience.keyboard.press("ArrowLeft");
await expectHashes(INITIAL_HASH, phone, audience);
await audience.evaluate((hash) => {
window.location.hash = hash;
}, DISCUSSION_HASH);
await expectHashes(DISCUSSION_HASH, phone, audience);
await expect(phone.getByText("Q&A", { exact: true }).first()).toBeVisible();
await audience.evaluate((hash) => {
window.location.hash = hash;
}, ARCHITECTURE_FOCUS_HASH);
await expectHashes(ARCHITECTURE_FOCUS_HASH, phone, audience);
await phone.reload();
await expectConnected(phone, audience);
await expectHashes(ARCHITECTURE_FOCUS_HASH, phone, audience);
await phone.getByRole("button", { name: "End presentation" }).click();
await phone.getByRole("button", { name: "End presentation now" }).click();
await Promise.all([
expect(phone.getByText("The presenter ended this session.")).toBeVisible(),
expect(audience.getByText("The presenter ended this session.")).toBeVisible(),
]);
} finally {
await phoneContext.close();
await audienceContext.close();
}
});
test("supports symmetric creation from the audience route", async ({ browser }) => {
const { creatorContext: audienceContext, creator: audience, joinerContext: phoneContext, joiner: phone } =
await openPair(browser, "/present", "code");
try {
await audience.keyboard.press("ArrowRight");
await expectHashes("#scene/thesis/substrate", audience, phone);
await phone.getByRole("link", { name: "← Previous", exact: true }).click();
await expectHashes(INITIAL_HASH, audience, phone);
await phone.getByRole("button", { name: "End presentation" }).click();
await phone.getByRole("button", { name: "End presentation now" }).click();
await Promise.all([
expect(phone.getByText("The presenter ended this session.")).toBeVisible(),
expect(audience.getByText("The presenter ended this session.")).toBeVisible(),
]);
} finally {
await audienceContext.close();
await phoneContext.close();
}
});
+4 -1
View File
@@ -8,18 +8,20 @@
"prebuild": "pnpm --filter @lda/presentation-sync build",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:presentation-sync:e2e:install": "playwright install chromium",
"test:presentation-sync:e2e": "playwright test e2e/presentation-sync.spec.ts --workers=1",
"pretypecheck": "pnpm --filter @lda/presentation-sync build",
"typecheck": "tsc -b --pretty false",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@lda/presentation-sync": "workspace:*",
"@assistant-ui/react": "^0.14.26",
"@dagrejs/dagre": "3.0.0",
"@fontsource-variable/newsreader": "5.2.10",
"@fontsource-variable/source-sans-3": "5.2.9",
"@fontsource/barlow-condensed": "5.2.8",
"@fontsource/ibm-plex-mono": "5.2.7",
"@lda/presentation-sync": "workspace:*",
"@use-gesture/react": "^10.3.1",
"@xyflow/react": "12.11.1",
"class-variance-authority": "^0.7.1",
@@ -37,6 +39,7 @@
"valibot": "1.4.2"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "4.3.2",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
+1 -1
View File
@@ -13,6 +13,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src"],
"include": ["src", "e2e"],
"references": [{ "path": "../../packages/presentation-sync" }]
}
+2 -1
View File
@@ -1,7 +1,7 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vitest/config";
import { configDefaults, defineConfig } from "vitest/config";
const backendPort = process.env.WEB_PORT ?? "8787";
@@ -46,6 +46,7 @@ export default defineConfig({
},
test: {
environment: "jsdom",
exclude: [...configDefaults.exclude, "e2e/**"],
setupFiles: "./src/test/setup.ts",
},
});
+38
View File
@@ -84,6 +84,9 @@ importers:
specifier: 1.4.2
version: 1.4.2([email protected])
devDependencies:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
'@tailwindcss/vite':
specifier: 4.3.2
version: 4.3.2([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
@@ -586,6 +589,11 @@ packages:
'@oxc-project/[email protected]':
resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==}
'@playwright/[email protected]':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -1896,6 +1904,11 @@ packages:
react-dom:
optional: true
[email protected]:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2237,6 +2250,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14}
@@ -3095,6 +3118,10 @@ snapshots:
'@oxc-project/[email protected]': {}
'@playwright/[email protected]':
dependencies:
playwright: 1.61.1
'@radix-ui/[email protected]': {}
'@radix-ui/[email protected]': {}
@@ -4385,6 +4412,9 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7([email protected])
[email protected]:
optional: true
[email protected]:
optional: true
@@ -4839,6 +4869,14 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
[email protected]:
dependencies:
nanoid: 3.3.15