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
+3
View File
@@ -210,6 +210,9 @@ pyrightconfig.json
# Local Superpowers/subagent progress ledgers # Local Superpowers/subagent progress ledgers
.superpowers/ .superpowers/
# Playwright browser-test output
web/apps/console/test-results/
# Local dependency source checkouts used for agent reference # Local dependency source checkouts used for agent reference
.repos/ .repos/
+4 -2
View File
@@ -492,11 +492,13 @@ Implementation:
[`presentation lifecycle story expansion plan`](historical/superpowers/plans/2026-07-09-presentation-lifecycle-story-expansion.md). [`presentation lifecycle story expansion plan`](historical/superpowers/plans/2026-07-09-presentation-lifecycle-story-expansion.md).
- Evidence assets and rehearsal timing: prepare fallback screenshots/recordings, - Evidence assets and rehearsal timing: prepare fallback screenshots/recordings,
expected run states, and a timed walkthrough checklist for a 15-minute defense. expected run states, and a timed walkthrough checklist for a 15-minute defense.
- Active design: LAN presentation synchronization pairs `/present` and - Completed: LAN presentation synchronization pairs `/present` and
`/presenter` through an ephemeral bidirectional room with uniform code/QR/URL `/presenter` through an ephemeral bidirectional room with uniform code/QR/URL
pairing, revision-ordered location updates, reconnect behavior, and explicit pairing, revision-ordered location updates, reconnect behavior, and explicit
presenter termination. Public-internet TLS is not part of the first slice: presenter termination. Public-internet TLS is not part of the first slice:
[`LAN presentation synchronization`](superpowers/specs/2026-07-14-lan-presentation-sync-design.md). [`LAN presentation synchronization design`](superpowers/specs/2026-07-14-lan-presentation-sync-design.md).
Implementation:
[`LAN presentation synchronization plan`](historical/superpowers/plans/2026-07-14-lan-presentation-sync.md).
Boundaries: this is not a production admin panel, generic visual workflow Boundaries: this is not a production admin panel, generic visual workflow
editor, scheduler, external Google Drive/mail integration, or benchmark evidence editor, scheduler, external Google Drive/mail integration, or benchmark evidence
@@ -0,0 +1,936 @@
# LAN Presentation Synchronization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Pair `/present` and `/presenter` over a trusted LAN so either route can navigate the same canonical presentation hash, reconnect to the latest server snapshot, and end the session from the presenter UI.
**Architecture:** Add a small `@lda/presentation-sync` package that owns bounded wire contracts and decoders shared by browser and server. `@lda/web-server` owns an in-memory room service plus Hono HTTP and WebSocket routes; the console owns one source-owned controller and one pairing panel reused by both routes. Navigation continues to flow through the existing canonical URL hash, so no storyboard behavior moves into the server or sync protocol.
**Tech Stack:** TypeScript 6, Effect Schema 3.21.4, Hono 4.12.27, `@hono/node-server` 2.0.6 WebSocket upgrade support, `ws` 8.21.0, React 19.2.7, `react-qr-code` 2.2.0, Vite 8.1.2, Vitest 4.1.9, Testing Library.
## Global Constraints
- The first release is trusted-LAN only. Do not add TLS, authentication claims, hosted relay behavior, or public-internet setup.
- Synchronize only complete canonical hashes beginning with `#scene/` or `#discuss/`; do not synchronize scroll, forms, timers, disclosures, workflow actions, or local evidence state.
- Existing local controls remain authoritative for navigation semantics. The server stores hashes and revisions but must not import storyboard data.
- Local navigation must continue when pairing, WebSocket connection, or reconnection fails.
- The server snapshot wins after reconnect. Do not merge divergent local and remote histories.
- A room remains reconnectable for 10 minutes after its final connection closes and expires after 2 hours without activity.
- The location revision is monotonic. The first publish at a base revision wins; stale publishers receive the accepted snapshot.
- Pairing works symmetrically from `/present` and `/presenter` through the same panel. A created link targets the opposite route.
- Only a client joined with role `presenter` may send `session.end`.
- Every inbound HTTP and WebSocket payload is bounded and schema-decoded before use.
- Add comments around remote-application suppression, stale-revision convergence, room expiry, and WebSocket proxy behavior because those seams are non-obvious.
- Use TDD and focused test commands in every task. Preserve unrelated working-tree changes.
---
### Task 1: Shared Presentation Sync Contracts
**Files:**
- Create: `web/packages/presentation-sync/package.json`
- Create: `web/packages/presentation-sync/tsconfig.json`
- Create: `web/packages/presentation-sync/src/protocol.ts`
- Create: `web/packages/presentation-sync/src/protocol.test.ts`
- Create: `web/packages/presentation-sync/src/index.ts`
- Modify: `web/package.json`
**Interfaces:**
- Produces `PresentationRole`, `PresentationSnapshot`, `PresentationPresence`, `ClientSyncMessage`, `ServerSyncMessage`, `CreateSessionRequest`, `JoinSessionRequest`, and `SessionGrant`.
- Produces `decodeClientSyncMessage`, `decodeServerSyncMessage`, `decodeCreateSessionRequest`, and `decodeJoinSessionRequest`, each returning a discriminated `{ ok: true, value } | { ok: false, error }` result without exposing Effect internals.
- Produces `isCanonicalPresentationHash`, `normalizeJoinCode`, `MAX_SYNC_MESSAGE_BYTES`, and `MAX_PRESENTATION_HASH_LENGTH`.
- [x] **Step 1: Scaffold the shared package and make the workspace build it first**
Create `web/packages/presentation-sync/package.json` with the same private ESM package pattern as `@lda/workflow-rpc`:
```json
{
"name": "@lda/presentation-sync",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"effect": "3.21.4"
},
"devDependencies": {
"@types/node": "26.1.0",
"vitest": "4.1.9"
}
}
```
Create `web/packages/presentation-sync/tsconfig.json` by copying the compiler shape from `web/packages/rpc/tsconfig.json`, changing only the package path naturally. Modify `web/package.json` so `build` explicitly builds `@lda/presentation-sync` before the console and web server.
- [x] **Step 2: Write failing protocol tests**
Cover these exact behaviors in `protocol.test.ts`:
```ts
it("accepts bounded canonical location publishes", () => {
expect(decodeClientSyncMessage(JSON.stringify({
type: "location.publish",
hash: "#scene/architecture/client/focus/client-operations",
baseRevision: 4,
messageId: "msg-4",
}))).toEqual({
ok: true,
value: {
type: "location.publish",
hash: "#scene/architecture/client/focus/client-operations",
baseRevision: 4,
messageId: "msg-4",
},
});
});
it.each(["", "#unknown/x", "scene/thesis/title"])(
"rejects non-canonical hash %s",
(hash) => expect(isCanonicalPresentationHash(hash)).toBe(false),
);
it("rejects oversized websocket payloads before JSON decoding", () => {
const result = decodeClientSyncMessage("x".repeat(MAX_SYNC_MESSAGE_BYTES + 1));
expect(result).toEqual({ ok: false, error: "message_too_large" });
});
it("normalizes human-entered join codes", () => {
expect(normalizeJoinCode(" ab-cd 7 ")).toBe("ABCD7");
});
```
Also test unknown variants, negative/fractional revisions, overlong hashes, invalid roles, valid `session.end`/`ping`, all server message variants, and malformed JSON.
- [x] **Step 3: Run the protocol tests and confirm RED**
Run: `pnpm --dir web --filter @lda/presentation-sync test`
Expected: FAIL because `src/protocol.ts` and its exports do not exist.
- [x] **Step 4: Implement bounded Effect Schema decoders**
In `protocol.ts`, define the wire model with these exact bounds and variants:
```ts
export const MAX_SYNC_MESSAGE_BYTES = 16 * 1024;
export const MAX_PRESENTATION_HASH_LENGTH = 2_048;
export const JOIN_CODE_LENGTH = 6;
export type PresentationRole = "presenter" | "audience";
export type PresentationSnapshot = {
readonly hash: string;
readonly revision: number;
};
export type PresentationPresence = {
readonly presenters: number;
readonly audience: number;
};
export type CreateSessionRequest = {
readonly role: PresentationRole;
readonly initialHash: string;
};
export type JoinSessionRequest = {
readonly role: PresentationRole;
readonly code: string;
};
export type SessionGrant = {
readonly sessionId: string;
readonly code: string;
readonly connectionToken: string;
readonly websocketPath: "/api/presentation-sync/ws";
readonly snapshot: PresentationSnapshot;
};
export type ClientSyncMessage =
| { readonly type: "location.publish"; readonly hash: string; readonly baseRevision: number; readonly messageId: string }
| { readonly type: "session.end" }
| { readonly type: "ping"; readonly nonce: string };
export type ServerSyncMessage =
| { readonly type: "location.snapshot"; readonly snapshot: PresentationSnapshot; readonly originatingMessageId: string | null }
| { readonly type: "presence.snapshot"; readonly presence: PresentationPresence }
| { readonly type: "location.rejected"; readonly reason: "stale_revision"; readonly current: PresentationSnapshot; readonly messageId: string }
| { readonly type: "session.ended"; readonly reason: "presenter_ended" | "expired" }
| { readonly type: "protocol.error"; readonly code: "invalid_message" | "message_too_large" | "forbidden"; readonly message: string };
```
Use Effect Schema for runtime shape decoding. Keep the public decoder result plain and stable:
```ts
export type DecodeResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: "invalid_json" | "invalid_message" | "message_too_large" };
```
`isCanonicalPresentationHash` must enforce the length bound and only `#scene/` or `#discuss/` prefixes. Bound `messageId` and `nonce` to 128 characters. `normalizeJoinCode` must remove spaces and hyphens, uppercase the value, and leave final length validation to the request decoder.
- [x] **Step 5: Export the contract and verify GREEN**
Export every public symbol from `src/index.ts`. Run:
```powershell
pnpm --dir web --filter @lda/presentation-sync test
pnpm --dir web --filter @lda/presentation-sync typecheck
pnpm --dir web --filter @lda/presentation-sync build
```
Expected: all commands pass.
- [x] **Step 6: Commit the shared protocol**
```powershell
git add web/package.json web/packages/presentation-sync
git commit -m "feat: define presentation sync protocol"
```
---
### Task 2: In-Memory Room State Machine
**Files:**
- Create: `web/apps/server/src/presentation-sync/rooms.ts`
- Create: `web/apps/server/src/presentation-sync/rooms.test.ts`
- Modify: `web/apps/server/package.json`
- Modify: `web/apps/server/tsconfig.json`
**Interfaces:**
- Consumes the shared protocol types from Task 1.
- Produces `createPresentationRoomService(options?)` returning `create`, `join`, `connect`, `disconnect`, `publish`, `ping`, `end`, and `sweepExpired` methods.
- Produces `PresentationPeer`, a transport-neutral `{ send(message), close(code, reason) }` interface so room logic does not import Hono or `ws`.
- [x] **Step 1: Add shared package references**
Add `@lda/presentation-sync: "workspace:*"` to `@lda/web-server` dependencies. Add a TypeScript project reference to `../../packages/presentation-sync`. Update the server's `predev`, `prebuild`, and `pretypecheck` scripts to build both shared packages before server compilation.
- [x] **Step 2: Write failing room-state tests with a fake clock and peers**
Use a deterministic clock and token/code generator. Pin these behaviors:
```ts
const peer = () => ({
send: vi.fn(),
close: vi.fn(),
});
it("creates a room at revision zero and joins the opposite role", () => {
const service = makeService();
const created = service.create({ role: "presenter", initialHash: "#scene/thesis/title" });
const joined = service.join({ role: "audience", code: created.code });
expect(created.snapshot).toEqual({ hash: "#scene/thesis/title", revision: 0 });
expect(joined.sessionId).toBe(created.sessionId);
});
it("accepts one publish and rejects a stale competing publish", () => {
const { service, presenterToken, audienceToken } = connectedRoom();
expect(service.publish(presenterToken, {
type: "location.publish",
hash: "#scene/problem/direct-actions",
baseRevision: 0,
messageId: "presenter-1",
}).kind).toBe("accepted");
expect(service.publish(audienceToken, {
type: "location.publish",
hash: "#scene/positioning/landscape",
baseRevision: 0,
messageId: "audience-stale",
})).toEqual({
kind: "stale",
current: { hash: "#scene/problem/direct-actions", revision: 1 },
});
});
```
Also test unique codes, initial snapshot/presence on connect, duplicate-token connection replacement, disconnect presence, 10-minute reconnect grace, 2-hour inactivity expiry, ping activity, presenter termination, audience termination rejection, and broadcast isolation between rooms.
- [x] **Step 3: Run the room tests and confirm RED**
Run: `pnpm --dir web --filter @lda/web-server test -- src/presentation-sync/rooms.test.ts`
Expected: FAIL because `rooms.ts` does not exist.
- [x] **Step 4: Implement the room service**
Use these public constants and method boundary:
```ts
export const EMPTY_ROOM_GRACE_MS = 10 * 60 * 1_000;
export const ROOM_INACTIVITY_TTL_MS = 2 * 60 * 60 * 1_000;
export type PresentationPeer = {
readonly send: (message: ServerSyncMessage) => void;
readonly close: (code: number, reason: string) => void;
};
export const createPresentationRoomService = (options: {
readonly now?: () => number;
readonly makeId?: () => string;
readonly makeCode?: () => string;
readonly makeToken?: () => string;
} = {}) => ({
create(input: { readonly role: PresentationRole; readonly initialHash: string }): SessionGrant,
join(input: { readonly role: PresentationRole; readonly code: string }): SessionGrant,
connect(token: string, peer: PresentationPeer): ConnectResult,
disconnect(token: string, peer: PresentationPeer): void,
publish(token: string, peer: PresentationPeer, message: Extract<ClientSyncMessage, { type: "location.publish" }>): PublishResult,
ping(token: string, peer: PresentationPeer): void,
end(token: string, peer: PresentationPeer): EndResult,
sweepExpired(): number,
});
```
Internally keep `roomsById`, `roomIdByCode`, and `membershipByToken` maps. Do not delete membership tokens on socket disconnect; they are the reconnect credential. Match both token and peer identity during disconnect and every state-changing message so a replaced socket's late close or message event cannot affect the newer connection. Record `emptySince` only when the final peer leaves. On accepted publish, increment the revision once and broadcast one `location.snapshot`. On stale publish, send only the publishing peer a `location.rejected`. Add a comment explaining first-writer-wins revision convergence.
- [x] **Step 5: Verify room behavior**
Run:
```powershell
pnpm --dir web --filter @lda/web-server test -- src/presentation-sync/rooms.test.ts
pnpm --dir web --filter @lda/web-server typecheck
```
Expected: focused tests and typecheck pass.
- [x] **Step 6: Commit the room state machine**
```powershell
git add web/apps/server/package.json web/apps/server/tsconfig.json web/apps/server/src/presentation-sync/rooms.ts web/apps/server/src/presentation-sync/rooms.test.ts web/pnpm-lock.yaml
git commit -m "feat: add presentation sync room state"
```
---
### Task 3: Hono Session And WebSocket Endpoints
**Files:**
- Create: `web/apps/server/src/presentation-sync/routes.ts`
- Create: `web/apps/server/src/presentation-sync/routes.test.ts`
- Modify: `web/apps/server/src/app.ts`
- Modify: `web/apps/server/src/index.ts`
- Modify: `web/apps/server/package.json`
- Modify: `web/apps/console/vite.config.ts`
**Interfaces:**
- Consumes `PresentationRoomService` from Task 2 and `upgradeWebSocket` from `@hono/node-server`.
- Produces `addPresentationSyncRoutes(app, { rooms, upgradeWebSocket })`.
- Exposes `POST /api/presentation-sync/sessions`, `POST /api/presentation-sync/sessions/join`, and `GET /api/presentation-sync/ws?token=...`.
- [x] **Step 1: Install the official Node WebSocket dependencies**
Run:
```powershell
pnpm --dir web --filter @lda/web-server add ws@8.21.0
pnpm --dir web --filter @lda/web-server add -D @types/ws@8.18.1
```
Use `upgradeWebSocket` from the already-installed `@hono/node-server`; do not add a second Hono WebSocket adapter.
- [x] **Step 2: Write failing HTTP and two-client WebSocket tests**
Start a real ephemeral Node server in the test with `new WebSocketServer({ noServer: true })`, `serve({ fetch: app.fetch, websocket: { server: wss }, port: 0 })`, and `ws` clients. Test:
- valid creation returns `201`, code, token, snapshot, and `/api/presentation-sync/ws` path;
- invalid hash and invalid/expired code return bounded `400`/`404` JSON errors;
- both sockets receive initial location and presence snapshots;
- presenter and audience updates propagate in both directions;
- stale publish returns `location.rejected` with the accepted snapshot;
- reconnecting with the same token receives the latest snapshot;
- audience `session.end` receives `protocol.error` without ending the room;
- presenter `session.end` broadcasts `session.ended` and closes peers;
- oversized and malformed messages are rejected without crashing the room.
The core propagation assertion should read:
```ts
presenter.send(JSON.stringify({
type: "location.publish",
hash: "#scene/planner-runtime/boundary",
baseRevision: 0,
messageId: "p-1",
}));
await expectMessage(audience, (message) =>
message.type === "location.snapshot" && message.snapshot.revision === 1
);
```
- [x] **Step 3: Run endpoint tests and confirm RED**
Run: `pnpm --dir web --filter @lda/web-server test -- src/presentation-sync/routes.test.ts`
Expected: FAIL because no synchronization routes exist.
- [x] **Step 4: Implement HTTP setup and WebSocket routing**
Create route setup with this boundary:
```ts
export const addPresentationSyncRoutes = (
app: Hono,
dependencies: {
readonly rooms: PresentationRoomService;
readonly upgradeWebSocket: typeof upgradeWebSocket;
},
): void => { /* register the three routes */ };
```
Creation and join responses must use this shape:
```ts
type SessionGrant = {
readonly sessionId: string;
readonly code: string;
readonly connectionToken: string;
readonly websocketPath: "/api/presentation-sync/ws";
readonly snapshot: PresentationSnapshot;
};
```
The WebSocket route reads `token` from the query string. Its callbacks adapt Hono's socket context to a stable `PresentationPeer`, decode every client frame, call the peer-aware `rooms.publish`, `rooms.ping`, or `rooms.end`, and call `rooms.disconnect(token, peer)` on close. Keep messages text-only. Invalid binary/oversized input receives a bounded protocol error and closes with code `1009` or `1003` as appropriate.
- [x] **Step 5: Wire server startup and expiry sweep**
Change `createApp` to accept a required `presentationSync` dependency containing `rooms` and `upgradeWebSocket`, then register sync routes before static routes. Update every `createApp` test call to use a small helper that supplies a fresh room service.
In `index.ts`:
```ts
const rooms = createPresentationRoomService();
const wss = new WebSocketServer({ noServer: true });
const app = createApp({
runOperation,
presentationSync: { rooms, upgradeWebSocket },
...(staticConsoleRoot ? { consoleRoot: staticConsoleRoot } : {}),
});
const server = serve({
fetch: app.fetch,
hostname,
port,
websocket: { server: wss },
});
const expirySweep = setInterval(() => rooms.sweepExpired(), 60_000);
expirySweep.unref();
```
Clear `expirySweep`, close active WebSocket clients, and close `wss` during shutdown before waiting for the HTTP server. Bound forced termination so connected clients cannot hold shutdown open indefinitely. Add a comment that the periodic sweep enforces reconnect grace and inactivity expiry without persisting rooms.
- [x] **Step 6: Enable Vite WebSocket proxying**
Add `ws: true` to the existing `/api` proxy in `web/apps/console/vite.config.ts`. Keep the current HTTP error handler. Add a short comment that presentation synchronization shares the API origin in development while Vite HMR keeps its own socket.
- [x] **Step 7: Verify server integration**
Run:
```powershell
pnpm --dir web --filter @lda/web-server test -- src/presentation-sync/routes.test.ts src/app.test.ts
pnpm --dir web --filter @lda/web-server typecheck
pnpm --dir web --filter @lda/web-server build
```
Expected: all pass with no open handles.
- [x] **Step 8: Commit the server transport**
```powershell
git add web/apps/server web/apps/console/vite.config.ts web/pnpm-lock.yaml
git commit -m "feat: serve presentation sync sessions"
```
---
### Task 4: Browser Synchronization Controller
**Files:**
- Create: `web/apps/console/src/presentation/sync/presentation-sync-state.ts`
- Create: `web/apps/console/src/presentation/sync/presentation-sync-state.test.ts`
- Create: `web/apps/console/src/presentation/sync/presentation-sync-client.ts`
- Create: `web/apps/console/src/presentation/sync/presentation-sync-client.test.ts`
- Create: `web/apps/console/src/presentation/sync/usePresentationSync.ts`
- Create: `web/apps/console/src/presentation/sync/usePresentationSync.test.tsx`
- Modify: `web/apps/console/package.json`
- Modify: `web/apps/console/tsconfig.json`
**Interfaces:**
- Produces `usePresentationSync({ role, currentHash, applyRemoteHash })`.
- Produces `PresentationSyncController` with `state`, `startSession`, `joinSession`, `retry`, `leaveSession`, and `endSession`.
- Stores only the reconnect grant in session storage under `lda.presentation-sync.connection.v1`; it does not persist room snapshots or navigation history.
- [x] **Step 1: Add the shared contract dependency**
Add `@lda/presentation-sync: "workspace:*"` to the console dependencies and a TypeScript project reference to `../../packages/presentation-sync`. Add `predev`, `prebuild`, and `pretypecheck` scripts that build the shared package.
- [x] **Step 2: Write reducer tests for every controller state**
Define and test this union:
```ts
export type ConnectedSyncState = {
readonly grant: SessionGrant;
readonly snapshot: PresentationSnapshot;
readonly presence: PresentationPresence;
};
export type PresentationSyncState =
| { readonly kind: "standalone" }
| { readonly kind: "creating" }
| { readonly kind: "joining"; readonly code: string }
| ConnectedSyncState & { readonly kind: "waiting" | "connected" | "reconnecting" }
| { readonly kind: "failed"; readonly message: string; readonly retryable: boolean }
| { readonly kind: "ended"; readonly reason: "presenter_ended" | "expired" | "left" };
export type PresentationSyncController = {
readonly state: PresentationSyncState;
readonly startSession: () => Promise<void>;
readonly joinSession: (code: string) => Promise<void>;
readonly retry: () => void;
readonly leaveSession: () => void;
readonly endSession: () => void;
};
```
Test create/join progress, grant receipt, presence-driven waiting versus connected, reconnect state, accepted snapshots, stale rejection convergence, explicit end, local leave, and retryable failure.
- [x] **Step 3: Write client transport tests**
Use fake `fetch`, `WebSocket`, timer, session storage, and location objects. Pin:
- same-origin HTTP paths;
- `ws://` for HTTP and `wss://` for HTTPS;
- opposite-route join URL construction;
- QR/manual code normalization;
- reconnect delays of `500`, `1_000`, `2_000`, then capped at `5_000` ms;
- saved grant restoration after reload;
- no reconnect after `session.ended` or explicit leave.
- [x] **Step 4: Write hook tests for hash publication and suppression**
The critical feedback-loop test must assert exactly one publish:
```ts
it("applies a remote hash without publishing it back", async () => {
const applyRemoteHash = vi.fn();
const { rerender } = renderHook(
({ hash }) => usePresentationSync({ role: "audience", currentHash: hash, applyRemoteHash }),
{ initialProps: { hash: "#scene/thesis/title" } },
);
fakeSocket.serverMessage(snapshot("#scene/problem/direct-actions", 1));
expect(applyRemoteHash).toHaveBeenCalledWith("#scene/problem/direct-actions");
rerender({ hash: "#scene/problem/direct-actions" });
expect(fakeSocket.sent.filter(isLocationPublish)).toHaveLength(0);
});
```
Also test one local publish, stale snapshot application, server-snapshot-wins reconnect, standalone behavior after server failure, query-string auto-join, and cleanup on unmount.
- [x] **Step 5: Run controller tests and confirm RED**
Run:
```powershell
pnpm --dir web --filter @lda/console test -- src/presentation/sync/presentation-sync-state.test.ts src/presentation/sync/presentation-sync-client.test.ts src/presentation/sync/usePresentationSync.test.tsx
```
Expected: FAIL because the controller modules do not exist.
- [x] **Step 6: Implement the pure reducer and transport**
Implement `createPresentationSyncClient` with injected browser dependencies for testability:
```ts
export const createPresentationSyncClient = (dependencies: {
readonly fetch: typeof fetch;
readonly createWebSocket: (url: string) => WebSocket;
readonly storage: Storage;
readonly origin: string;
readonly protocol: string;
readonly setTimeout: typeof window.setTimeout;
readonly clearTimeout: typeof window.clearTimeout;
}) => ({ create, join, connect, publish, end, leave });
```
Use a monotonic client message counter plus `crypto.randomUUID()` for message IDs. Decode every server frame through `decodeServerSyncMessage`. Cap reconnect delay at 5 seconds. Preserve the grant in session storage after transient disconnect, but delete it on explicit leave, explicit end, expired room, or invalid token.
- [x] **Step 7: Implement `usePresentationSync`**
The hook must:
1. restore a saved grant or consume `?pair=CODE` once on mount;
2. connect and apply the initial server snapshot before publishing anything;
3. publish local `currentHash` changes only when connected;
4. set `remoteHashInFlightRef` immediately before `applyRemoteHash`;
5. suppress the next matching `currentHash` effect, then clear the ref;
6. preserve standalone local navigation on all failures; and
7. expose stable action functions.
Add a comment at steps 4-5 explaining why applying a remote hash must not echo it back as another revision.
- [x] **Step 8: Verify the controller**
Run:
```powershell
pnpm --dir web --filter @lda/console test -- src/presentation/sync
pnpm --dir web --filter @lda/console typecheck
```
Expected: all sync controller tests and typecheck pass.
- [x] **Step 9: Commit the browser controller**
```powershell
git add web/apps/console/package.json web/apps/console/tsconfig.json web/apps/console/src/presentation/sync web/pnpm-lock.yaml
git commit -m "feat: add presentation sync client"
```
---
### Task 5: Uniform Pairing Panel And QR Flow
**Files:**
- Create: `web/apps/console/src/presentation/sync/PresentationPairingPanel.tsx`
- Create: `web/apps/console/src/presentation/sync/PresentationPairingPanel.test.tsx`
- Create: `web/apps/console/src/presentation/sync/presentation-sync.css`
- Modify: `web/apps/console/package.json`
**Interfaces:**
- Consumes `PresentationSyncController`, current route role, and opposite-route join URL from Task 4.
- Produces one reusable panel for both audience and presenter routes.
- [x] **Step 1: Install the QR component**
Run: `pnpm --dir web --filter @lda/console add [email protected]`
- [x] **Step 2: Write failing panel tests**
Test all visible states:
- standalone collapsed trigger named **Pair presentation**;
- expanded Start session and code-entry controls;
- creating/joining disabled controls;
- waiting state with six-character code, QR value, copyable opposite-route URL, and peer counts;
- connected and reconnecting status copy;
- retryable failure;
- ended state;
- presenter-only two-step **End presentation** confirmation;
- no end action for audience role.
Use the real `QRCode` component but assert its wrapper's accessible label and `data-qr-value`, not SVG internals.
- [x] **Step 3: Run panel tests and confirm RED**
Run: `pnpm --dir web --filter @lda/console test -- src/presentation/sync/PresentationPairingPanel.test.tsx`
Expected: FAIL because the panel does not exist.
- [x] **Step 4: Implement the compact panel**
Use a single component API:
```ts
type PresentationPairingPanelProps = {
readonly role: PresentationRole;
readonly controller: PresentationSyncController;
};
```
The expanded panel must use ordinary form semantics, uppercase the visible code, show `react-qr-code` only after creation, and use `navigator.clipboard.writeText` behind a small copy action with an in-DOM status message. Do not make pairing a full-screen modal. Keep the collapsed connected surface readable on a phone and non-dominant on the audience deck.
The join URL is:
```ts
const oppositePath = role === "presenter" ? "/present" : "/presenter";
const joinUrl = `${window.location.origin}${oppositePath}?pair=${state.code}`;
```
- [x] **Step 5: Add restrained styling**
Create `presentation-sync.css` with the existing editorial token vocabulary. Use one compact bordered surface, a two-column QR/details layout when expanded, and a single-column layout below 640 px. Do not introduce gradients, oversized pills, or a second theme.
- [x] **Step 6: Verify panel behavior**
Run:
```powershell
pnpm --dir web --filter @lda/console test -- src/presentation/sync/PresentationPairingPanel.test.tsx
pnpm --dir web --filter @lda/console typecheck
```
Expected: tests and typecheck pass.
- [x] **Step 7: Commit the pairing surface**
```powershell
git add web/apps/console/package.json web/apps/console/src/presentation/sync web/pnpm-lock.yaml
git commit -m "feat: add presentation pairing panel"
```
---
### Task 6: Synchronize `/present`
**Files:**
- Modify: `web/apps/console/src/presentation/PresentationRoute.tsx`
- Modify: `web/apps/console/src/presentation/PresentationRoute.test.tsx`
- Modify: `web/apps/console/src/presentation/PresentationStage.tsx`
- Modify: `web/apps/console/src/presentation/PresentationFooter.tsx`
- Modify: `web/apps/console/src/presentation/PresentationFooter.test.tsx`
- Modify: `web/apps/console/src/presentation/presentation.css`
**Interfaces:**
- Consumes `usePresentationSync` and `PresentationPairingPanel`.
- Publishes the audience route's existing `hashForLocation(state.location)` and applies remote hashes through the existing `jump_hash` reducer path.
- [x] **Step 1: Write failing route integration tests**
Mock only the sync transport, not the presentation reducer. Test:
- panel is visible on `/present`;
- local ArrowRight changes the hash and publishes once;
- direct hash and figure focus changes publish the complete focus path;
- a remote discussion hash enters the existing discussion panel;
- a remote hash is not echoed;
- server failure leaves Space/arrow navigation working.
- [x] **Step 2: Run audience integration tests and confirm RED**
Run: `pnpm --dir web --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx`
Expected: new pairing and synchronization assertions fail.
- [x] **Step 3: Mount the shared controller**
In `PresentationRoute`, compute:
```ts
const canonicalHash = hashForLocation(state.location);
const presentationSync = usePresentationSync({
role: "audience",
currentHash: canonicalHash,
applyRemoteHash: (hash) => dispatch({ type: "jump_hash", hash }),
});
```
Do not replace the existing hashchange listener, keyboard listener, reducer, or `history.replaceState` effect. Thread the controller through `PresentationStage` to `PresentationFooter` and render `PresentationPairingPanel` in the footer's non-demo utility area. It must not displace the live workflow demo rail.
- [x] **Step 4: Verify the audience route**
Run:
```powershell
pnpm --dir web --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx src/presentation/PresentationFooter.test.tsx
pnpm --dir web --filter @lda/console typecheck
```
Expected: focused tests pass.
- [x] **Step 5: Commit audience synchronization**
```powershell
git add web/apps/console/src/presentation
git commit -m "feat: synchronize audience presentation route"
```
---
### Task 7: Synchronize `/presenter` And End Sessions
**Files:**
- Modify: `web/apps/console/src/presentation/presenter/PresenterRoute.tsx`
- Modify: `web/apps/console/src/presentation/presenter/PresenterRoute.test.tsx`
- Modify: `web/apps/console/src/presentation/presenter/PresenterShell.tsx`
- Modify: `web/apps/console/src/presentation/presenter/PresenterShell.test.tsx`
- Modify: `web/apps/console/src/presentation/presenter/PresenterNavigationBar.tsx`
- Modify: `web/apps/console/src/presentation/presenter/presenter.css`
**Interfaces:**
- Consumes the same controller and panel as Task 6 with role `presenter`.
- Publishes `presenterHashForNote`/current canonical hash changes and applies remote hashes by assigning `window.location.hash`, allowing the existing presenter navigation parser to update notes and Q&A.
- [x] **Step 1: Write failing presenter integration tests**
Test:
- the pairing panel appears in the stable presenter navigation area;
- Previous, Next, arrow keys, swipe callbacks, sidebar links, and Q&A links publish canonical hashes;
- audience-originated hashes update the visible presenter note;
- a remote Q&A hash opens the correct Q&A content;
- remote application does not publish a feedback message;
- presenter end confirmation calls `session.end` and displays ended state;
- local navigation still works after socket failure.
- [x] **Step 2: Run presenter tests and confirm RED**
Run:
```powershell
pnpm --dir web --filter @lda/console test -- src/presentation/presenter/PresenterRoute.test.tsx src/presentation/presenter/PresenterShell.test.tsx
```
Expected: new synchronization assertions fail.
- [x] **Step 3: Mount the shared controller and panel**
Use the URL as the source for the current hash because rapid swipe/key events already resolve from it:
```ts
const presentationSync = usePresentationSync({
role: "presenter",
currentHash: window.location.hash || "#scene/thesis/title",
applyRemoteHash: (hash) => {
if (window.location.hash !== hash) window.location.hash = hash;
},
});
```
Render the panel beside the stable Previous/Next controls, not inside scrolling speaker-note content. Preserve the current mobile swipe target exclusions and auto-scroll behavior.
- [x] **Step 4: Verify presenter behavior**
Run:
```powershell
pnpm --dir web --filter @lda/console test -- src/presentation/presenter
pnpm --dir web --filter @lda/console typecheck
```
Expected: presenter tests and typecheck pass.
- [x] **Step 5: Commit presenter synchronization**
```powershell
git add web/apps/console/src/presentation/presenter
git commit -m "feat: synchronize presenter controls"
```
---
### Task 8: Two-Context Browser Verification And LAN Runbook
**Files:**
- Create: `web/apps/console/e2e/presentation-sync.spec.ts`
- Modify: `web/apps/console/package.json`
- Modify: `web/README.md`
- Modify: `docs/current_roadmap.md`
- Modify: `docs/project_map.md`
- Move: `docs/superpowers/plans/2026-07-14-lan-presentation-sync.md` to `docs/historical/superpowers/plans/2026-07-14-lan-presentation-sync.md`
**Interfaces:**
- Verifies the complete server/browser contract without introducing production APIs.
- Documents the exact LAN launch, pairing, recovery, and security boundary.
- [x] **Step 1: Add a browser test script using the repository's existing Playwright CLI dependency strategy**
Add a `test:presentation-sync:e2e` script that starts from a built console and Hono server. Do not add a second permanent browser-test framework if Playwright is already available through the local tooling; otherwise add `@playwright/test` as a console dev dependency and commit the lockfile.
- [x] **Step 2: Write the two-context browser test**
The test must:
1. open `/presenter#scene/thesis/title` in a phone-sized context;
2. create a room and read the code;
3. open `/present?pair=CODE#scene/thesis/title` in a 1280x720 context;
4. press Next on the phone and assert both hashes match;
5. press ArrowLeft on the audience page and assert both hashes match;
6. navigate the audience page to an architecture focus hash and assert the phone follows;
7. reload the phone and assert it receives the latest server snapshot;
8. end from the phone and assert both panels show ended state;
9. repeat creation from `/present` and joining from `/presenter` to verify symmetry.
- [x] **Step 3: Run the complete verification matrix**
Run:
```powershell
pnpm --dir web test
pnpm --dir web typecheck
pnpm --dir web build
pnpm --dir web --filter @lda/console test:presentation-sync:e2e
git diff --check
```
Expected: all unit/integration tests, typechecks, build, browser test, and whitespace check pass. The existing Vite chunk warning is acceptable; new runtime errors or open handles are not.
- [x] **Step 4: Perform manual LAN smoke on two devices or two browser contexts**
Run:
```powershell
pnpm --dir web build
$env:WEB_HOST = "0.0.0.0"
pnpm --dir web start
```
Open `http://<laptop-lan-ip>:8787/presenter` and `http://<laptop-lan-ip>:8787/present`. Verify code entry, QR target, navigation in both directions, reconnect after reload, and presenter termination. Confirm the workflow RPC server remains loopback-only and browser workflow operations still pass through Hono.
- [x] **Step 5: Update live documentation**
In `web/README.md`, document:
- build/start commands;
- `WEB_HOST=0.0.0.0` and port 8787;
- symmetric Start/Join behavior;
- QR/code/link pairing;
- reconnect and server-snapshot-wins behavior;
- presenter termination;
- trusted-LAN-only boundary; and
- the fact that `8765` may remain loopback-only behind Hono.
Mark the roadmap item completed and link the historical implementation plan. Keep the approved design spec live because it remains the current behavior contract.
Add `@lda/presentation-sync` to `docs/project_map.md` as the shared bounded wire-contract package used by the browser console and `@lda/web-server`. State explicitly that room lifecycle remains in the web server and navigation semantics remain in the console.
- [x] **Step 6: Run independent review and fix valid findings**
Use the `requesting-code-review` skill. Review along both Standards and Spec axes. Re-run focused tests for every corrected finding, then re-run typecheck and `git diff --check`.
- [x] **Step 7: Archive the completed plan and commit**
```powershell
git mv docs/superpowers/plans/2026-07-14-lan-presentation-sync.md docs/historical/superpowers/plans/2026-07-14-lan-presentation-sync.md
git add web/README.md web/apps/console/e2e web/apps/console/package.json web/pnpm-lock.yaml docs/current_roadmap.md docs/project_map.md docs/historical/superpowers/plans/2026-07-14-lan-presentation-sync.md
git commit -m "docs: complete LAN presentation sync"
```
## Final Acceptance Checklist
- [x] Starting from either route produces a code, QR, and opposite-route link.
- [x] Joining receives the latest canonical hash and revision immediately.
- [x] Phone presenter controls move the audience deck.
- [x] Audience keyboard, mouse, direct hash, discussion, and figure focus navigation update presenter notes.
- [x] A remote hash is never echoed as a second revision.
- [x] Concurrent same-revision updates converge to the first accepted snapshot.
- [x] Reconnect applies the server snapshot and does not overwrite it with offline local navigation.
- [x] The room survives a temporary disconnect, then expires at the documented bounds.
- [x] Only presenter-role clients can end the session.
- [x] Pairing failure never disables standalone navigation.
- [x] No storyboard data or workflow operations enter the room service.
- [x] No TLS or public-internet security claim appears in UI or docs.
- [x] Full tests, typecheck, build, two-context browser smoke, and `git diff --check` pass.
+9
View File
@@ -28,6 +28,15 @@ For verified Python 3.14 dependency constraints and their removal criteria, see
| `wf_sources_mcp` | MCP-as-upstream-source implementation: ids, registry DTOs, auth/catalog stores, discovery, SDK client/facade, runtime pool, wrappers. | `wf_server`, broker glue, MCP source tests. | | `wf_sources_mcp` | MCP-as-upstream-source implementation: ids, registry DTOs, auth/catalog stores, discovery, SDK client/facade, runtime pool, wrappers. | `wf_server`, broker glue, MCP source tests. |
| `wf_mcp` | MCP frontend/compatibility package: legacy `wf-mcp` entrypoints, broker glue, proxy/admin tools, and shims while extraction continues. | Compatibility callers and MCP transport work. | | `wf_mcp` | MCP frontend/compatibility package: legacy `wf-mcp` entrypoints, broker glue, proxy/admin tools, and shims while extraction continues. | Compatibility callers and MCP transport work. |
| `wf_cli` | Command-line frontend over local or remote workflow APIs. | Humans, scripts, agent skills. | | `wf_cli` | Command-line frontend over local or remote workflow APIs. | Humans, scripts, agent skills. |
| `@lda/presentation-sync` | Shared, bounded wire contract for ephemeral LAN presentation rooms. | Browser `@lda/console` and Hono `@lda/web-server`. |
The TypeScript presentation synchronization boundary is deliberately narrow.
`@lda/web-server` owns room creation, membership, revision ordering, expiry,
presence, and termination. `@lda/console` owns storyboard and navigation
semantics and publishes only canonical hashes through
`@lda/presentation-sync`; no storyboard data or workflow operation enters the
room service. Browser workflow operations remain behind the Hono server and
can continue to reach a loopback-only workflow RPC server.
## Important Entry Points ## Important Entry Points
+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 A single Hono process serves the built React application and API routes from
`http://127.0.0.1:8787`. `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 ## Commands
| Command | Description | | 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 typecheck` | Run TypeScript type checking |
| `pnpm --dir web build` | Build the React console for production | | `pnpm --dir web build` | Build the React console for production |
| `pnpm --dir web start` | Start the production Hono server | | `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 ## Architecture
@@ -58,6 +93,7 @@ web/
server/ Hono local server (API + static serving) server/ Hono local server (API + static serving)
packages/ packages/
rpc/ Effect-based JSON-RPC client, schemas, and errors 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 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 stage regions, discussion branches, one editorial canvas, persistent scene-aware
assistant surfaces, and keyboard navigation. 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 the same `#scene/<scene>/<beat>` and `#discuss/<branch>` hashes, shows target and
cumulative timing, keeps optional detail/evidence/Q&A collapsed, and links to 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, the corresponding audience slide in a new tab. It performs no workflow RPC,
replay, live-target, or cross-window synchronization. Use ArrowLeft and replay, or live-target operations. Its shared pairing controller synchronizes
ArrowRight to move between notes; covered checkboxes remain local to the page. 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 Must-say notes support authored inline Markdown emphasis for rapid scanning, and
the stable Previous/Next bar remains available at narrow viewport widths. the stable Previous/Next bar remains available at narrow viewport widths.
@@ -331,7 +369,7 @@ discussion transitions.
This plan deliberately defers: This plan deliberately defers:
- AI Elements / Vercel AI chat primitives - AI Elements / Vercel AI chat primitives
- Live LLM driver integration - Live LLM driver integration
- Remote phone control - Public-internet presentation hosting, TLS, and authentication
- Final visual polish and motion choreography - Final visual polish and motion choreography
### Constrained Demo Agent ### 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", "prebuild": "pnpm --filter @lda/presentation-sync build",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"test": "vitest run", "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", "pretypecheck": "pnpm --filter @lda/presentation-sync build",
"typecheck": "tsc -b --pretty false", "typecheck": "tsc -b --pretty false",
"preview": "vite preview --host 127.0.0.1" "preview": "vite preview --host 127.0.0.1"
}, },
"dependencies": { "dependencies": {
"@lda/presentation-sync": "workspace:*",
"@assistant-ui/react": "^0.14.26", "@assistant-ui/react": "^0.14.26",
"@dagrejs/dagre": "3.0.0", "@dagrejs/dagre": "3.0.0",
"@fontsource-variable/newsreader": "5.2.10", "@fontsource-variable/newsreader": "5.2.10",
"@fontsource-variable/source-sans-3": "5.2.9", "@fontsource-variable/source-sans-3": "5.2.9",
"@fontsource/barlow-condensed": "5.2.8", "@fontsource/barlow-condensed": "5.2.8",
"@fontsource/ibm-plex-mono": "5.2.7", "@fontsource/ibm-plex-mono": "5.2.7",
"@lda/presentation-sync": "workspace:*",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"@xyflow/react": "12.11.1", "@xyflow/react": "12.11.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -37,6 +39,7 @@
"valibot": "1.4.2" "valibot": "1.4.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "4.3.2", "@tailwindcss/vite": "4.3.2",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
+1 -1
View File
@@ -13,6 +13,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"], "include": ["src", "e2e"],
"references": [{ "path": "../../packages/presentation-sync" }] "references": [{ "path": "../../packages/presentation-sync" }]
} }
+2 -1
View File
@@ -1,7 +1,7 @@
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vitest/config"; import { configDefaults, defineConfig } from "vitest/config";
const backendPort = process.env.WEB_PORT ?? "8787"; const backendPort = process.env.WEB_PORT ?? "8787";
@@ -46,6 +46,7 @@ export default defineConfig({
}, },
test: { test: {
environment: "jsdom", environment: "jsdom",
exclude: [...configDefaults.exclude, "e2e/**"],
setupFiles: "./src/test/setup.ts", setupFiles: "./src/test/setup.ts",
}, },
}); });
+38
View File
@@ -84,6 +84,9 @@ importers:
specifier: 1.4.2 specifier: 1.4.2
version: 1.4.2([email protected]) version: 1.4.2([email protected])
devDependencies: devDependencies:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: 4.3.2 specifier: 4.3.2
version: 4.3.2([email protected](@types/[email protected])([email protected])([email protected])([email protected])) version: 4.3.2([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
@@ -586,6 +589,11 @@ packages:
'@oxc-project/[email protected]': '@oxc-project/[email protected]':
resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==}
'@playwright/[email protected]':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@radix-ui/[email protected]': '@radix-ui/[email protected]':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -1896,6 +1904,11 @@ packages:
react-dom: react-dom:
optional: true 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]: [email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2237,6 +2250,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'} 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]: [email protected]:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -3095,6 +3118,10 @@ snapshots:
'@oxc-project/[email protected]': {} '@oxc-project/[email protected]': {}
'@playwright/[email protected]':
dependencies:
playwright: 1.61.1
'@radix-ui/[email protected]': {} '@radix-ui/[email protected]': {}
'@radix-ui/[email protected]': {} '@radix-ui/[email protected]': {}
@@ -4385,6 +4412,9 @@ snapshots:
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7([email protected]) react-dom: 19.2.7([email protected])
[email protected]:
optional: true
[email protected]: [email protected]:
optional: true optional: true
@@ -4839,6 +4869,14 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
[email protected]: [email protected]:
dependencies: dependencies:
nanoid: 3.3.15 nanoid: 3.3.15