From 2d122461bb4e88ca9963a072da6fa4c9adad4951 Mon Sep 17 00:00:00 2001 From: chee Date: Fri, 13 Nov 2026 00:54:46 +0000 Subject: [PATCH 01/25] docs: call package design and plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-11-13-call-package.md | 345 ++++++++++++++++ .../specs/2026-11-12-call-package-design.md | 381 ++++++++++++++++++ 2 files changed, 726 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-13-call-package.md create mode 100644 docs/superpowers/specs/2026-11-12-call-package-design.md diff --git a/docs/superpowers/plans/2026-11-13-call-package.md b/docs/superpowers/plans/2026-11-13-call-package.md new file mode 100644 index 00000000..ef7a0b57 --- /dev/null +++ b/docs/superpowers/plans/2026-11-13-call-package.md @@ -0,0 +1,345 @@ +# call package 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 (`- [ ]`) syntax for tracking. + +**Goal:** A standalone `call/` package in patchwork-pkg-base (provider, doc-title button, control panel, video tab, face) that lets anyone call on any doc, and the threepane changes that host it. + +**Architecture:** lattice's call engine (`wm/call/*`) is ported into `base/call/src/engine/`, with its project-shaped API replaced by url-keyed rooms. All five plugins import that one engine as a shared chunk. Anything outside the package talks to the provider through `patchwork:call` events and three `patchwork:subscribe` selectors, plain data only. threepane mounts the provider outermost, renders the control panel by component id, gives sidebar widgets `fill`/`fit`/`none` sizing, and opens the video tab when asked. + +**Tech Stack:** vanilla JS + `solid-js` / `solid-js/html` (no JSX), vite bundle with bootloader externals and `vite-plugin-css-injected-by-js`, vitest + happy-dom, `@inkandswitch/patchwork-providers` for `subscribe`/`accept`. threepane stays TSX + esbuild. + +**Spec:** `docs/superpowers/specs/2026-11-12-call-package-design.md`. Read it before any task. The plan argues from it. + +**Source being ported:** `../lattice.patchwork.inkandswitch.com/packages/wm/call/`, `.../wm/{lasting,kept,pointer,presence}.js`, `.../wm/ui/{icons,along,arriving}.js`, `.../wm/vendor/deepfilternet/`, `.../lattice/call-bar.js`, and the call rules in `.../wm/wm.css` and `.../lattice/shell.css` (grep `lattice-wm-call`, `lattice-call`, `lattice-wm-menu`). Nothing in lattice is edited. + +**Branch:** work in base on a branch `call-package` (base is on `main`; leave any unrelated uncommitted changes alone and stage only the files each task names). + +## Global Constraints + +- Standalone package rules (base `AGENTS.md`): own `package.json`, `pnpm-lock.yaml` (committed), `pnpm-workspace.yaml` (committed, copied verbatim from a neighbour), `vitest.config.js` with `passWithNoTests: true`, `.gitignore` = `dist`, `node_modules`, `.pushwork`; no `workspace:`/`catalog:`/`link:`/`file:` specifiers; `pnpm lint` at the base root passes. +- No build-time coupling: threepane never imports from `call/`, and `call/` never imports from threepane. They meet by component id, tags, events and DOM attributes. +- Entry module (`src/index.js`) is parsed in a worker: plugin entries are metadata only, all code behind `load()` via dynamic `import()`, no bare external imports in its static graph. +- CSS: every class namespaced `patchwork-call-…`; colour/font tokens derived once in `@layer package { :root, :host, [theme] { --call-…: var(--studio-…) } }` (these are all studio chrome: sidebars, title bar, floating layer), style rules unlayered, no hex colours, no `prefers-color-scheme`. Import CSS from tool modules, never the entry. +- Ephemeral wire key stays `"lattice-wm:call"`. Settings stay in localStorage under `"lattice-wm:call"` (minus `cursors`). DeepFilterNet's IndexedDB name stays `"lattice-wm:deepfilternet"`. Page-lifetime `lasting` keys and the sessionStorage rejoin key become `"patchwork-call:…"` so they never collide with a lattice copy. +- A room id is a bare document url (`automerge:`, found in the page repo: `globalThis.repo ?? globalThis.patchwork?.repo`) or `key:<64 lowercase hex>`. Key rooms are designed for but not built: their channel opens to nothing. +- One call per tab, ever. +- The control panel takes no sidebar space when you're not in a call and no call is heard on screen. +- No media streams cross the protocol. +- Isolation mode is out of scope. + +## Review Focus + +1. **Mounted events not reaching the provider.** A doc opened deep inside threepane (inside the draft-overlay, comments and focus providers) must still be heard by the outermost `call-provider`. Expect a call on it to show in the control panel. Pinned by a provider test dispatching `patchwork:mounted` from a nested element (Task 4), and by the browser check (Task 11). +2. **The same doc mounted several times.** Title-bar tools, the editor and an embed all mount the same url. When one of them unmounts, hearing must carry on, and only the last unmount stops it. Pinned in Task 4. +3. **Draft and heads urls.** `automerge:#`, `automerge:/path` and the plain url are one room. Pinned by `roomUrl` tests (Task 2) and a provider test mounting a heads url (Task 4). +4. **Rejoin scope after reload.** Rejoin happens only when the remembered room comes back on screen (mounted or declared) within `REJOIN_WITHIN`. A room heard only because a button subscribed to it must not rejoin. Pinned in Task 3. +5. **The call package missing.** threepane still renders, the frame isn't blank, nothing waits on `call-provider`, and the doc-title button renders nothing when no provider answers. Pinned in Task 8 (button) and Task 9 (threepane). + +--- + +### Task 1: Package scaffold and the pure modules + +**Files:** +- Create: `call/package.json`, `call/pnpm-workspace.yaml` (copy `comments-view/pnpm-workspace.yaml`), `call/.gitignore`, `call/vite.config.js`, `call/vitest.config.js`, `call/src/index.js` +- Create (copied from wm, unchanged unless noted): `call/src/engine/lasting.js` (symbol becomes `Symbol.for("patchwork-call:lasting")`), `call/src/engine/kept.js`, `call/src/engine/rejoin.js` (`REJOIN_KEY = "patchwork-call:rejoin"`; import `./kept.js`), `call/src/engine/sizes.js`, `call/src/engine/wiggle.js` +- Test: `call/test/sizes.test.js`, `call/test/wiggle.test.js`, `call/test/kept.test.js` (the `kept:` and `rejoin:` cases of wm's `kept.test.js`; drop the `following:` ones), `call/test/lasting.test.js` + +**Interfaces:** +- Produces: `lasting(key, make)`, `kept(key, fallback?, storage?)`, `keep(key, value, storage?)`, `keepCall`/`leavingCall`/`forgetCall`/`callToRejoin`/`REJOIN_WITHIN` exactly as in wm. `sizes.js` and `wiggle.js` exports unchanged. +- `package.json`: `"name": "@tiny-patchwork/call"`, `"type": "module"`, `"private": true`, `"main": "./dist/index.js"`, scripts `build: vite build`, `dev: vite build --watch`, `sync: vite build && pushwork sync`, `register` (as comments-view), `test: vitest run`, `test:watch: vitest`. Deps at comments-view's versions: `@automerge/automerge`, `@automerge/automerge-repo`, `@inkandswitch/patchwork-filesystem`, `@inkandswitch/patchwork-plugins`, `@inkandswitch/patchwork-providers`, `solid-js`. Dev deps: `@inkandswitch/patchwork-bootloader`, `happy-dom`, `vite`, `vite-plugin-css-injected-by-js`, `vitest`. +- `vite.config.js`: comments-view's config without `vite-plugin-solid`, with `input: "./src/index.js"`. +- `vitest.config.js`: `environment: "happy-dom"`, `globals: true`, `passWithNoTests: true`, `resolve.conditions: ["development", "browser"]`. +- `src/index.js`: `export const plugins = []` for now. Each later task adds its entry. + +- [ ] **Step 1: Copy the four test files over, switching `node:test` to vitest.** Change `import { test } from "node:test"` (or the default import) to `import { test } from "vitest"`, keep `node:assert/strict`, and fix the import paths to `../src/engine/…`. For `lasting.test.js`, also update the key it checks if it asserts the symbol. +- [ ] **Step 2: Run `pnpm install && pnpm test` in `call/`.** Expected: FAIL, because the engine modules don't exist yet. +- [ ] **Step 3: Copy the five engine modules over** with the two key changes listed above. +- [ ] **Step 4: Run `pnpm test`, then `pnpm build`, then `pnpm lint` at the base root.** Expected: tests PASS, the build writes `dist/index.js`, lint reports nothing for `call/`. +- [ ] **Step 5: Commit** (`git checkout -b call-package` first) with the message `call: scaffold package, port pure modules from lattice`. + +### Task 2: Room ids, channels and room names + +**Files:** +- Create: `call/src/engine/room.js`, `call/src/engine/channel.js` +- Test: `call/test/room.test.js`, `call/test/channel.test.js` + +**Interfaces:** +- Produces: + - `roomUrl(url: unknown): string | null`. This is the room id normaliser (the name stays `roomUrl` because the protocol calls its fields `url`). For an automerge url it returns `automerge:`, with any `#…` fragment and `/…` path removed. For `key:` it returns the same, lowercased, if the hex is exactly 64 characters. Anything else, including an automerge url that isn't valid once trimmed (`isValidAutomergeUrl` from `@automerge/automerge-repo/slim`), returns `null`. + - `keyRoom(bytes: Uint8Array): string`. This returns `key:` plus the 32 bytes as hex, and throws `RangeError` unless `bytes.length === 32`. + - `isKeyRoom(id: string): boolean`. + - `pageRepo(): Repo | null`. This returns `globalThis.repo ?? globalThis.patchwork?.repo ?? null`. + - In `channel.js`, `openChannel(id: string): Promise`, where `Channel = { id: string, on(fn: (event: { message: unknown, senderId: string }) => void): void, off(fn): void, send(message: object): void }`. For a doc room it awaits `pageRepo().find(id)` (cached per id) and wraps the handle: `on` and `off` go through `handle.on/off("ephemeral-message")`, passing `{ message: e.message, senderId: String(e.senderId) }`, and `send` is `handle.broadcast`. A key room, no repo, or a failed `find` all resolve to `null` (log a warning for the failed find). + - `titleOf(handle: DocHandle): Promise`. This reads `doc["@patchwork"]?.type`, loads that datatype with `getRegistry("patchwork:datatype")`, and returns its `getTitle(doc)`. It falls back to `doc.title`, then `""`, and never throws. + +- [ ] **Step 1: Write the failing tests.** + - `roomUrl("automerge:2j9…")` returns itself. + - The same id with `#heads|abc` returns the bare url, and so does the same id with `/path/to`. + - `roomUrl("")`, `roomUrl(null)` and `roomUrl("https://x")` return `null`. + - `pageRepo()` returns `globalThis.repo` when it's set, `globalThis.patchwork.repo` when only that is set, and `null` otherwise. Restore the globals after each test. + - `roomUrl("KEY:" + "AB".repeat(32))` returns `"key:" + "ab".repeat(32)`, and a 63-character hex returns `null`. `keyRoom(new Uint8Array(32).fill(255))` returns `"key:" + "ff".repeat(32)`, and `keyRoom(new Uint8Array(31))` throws. + - In `channel.test.js`, `openChannel` for a doc url with a fake repo: `send({a:1})` calls `handle.broadcast({a:1})`, and a listener added with `on` gets `{ message, senderId }` when the fake handle emits. `openChannel("key:" + "00".repeat(32))` resolves to `null`, and so does a doc url when there's no repo. + - `titleOf` with a mocked `@inkandswitch/patchwork-plugins` (`vi.mock`, registry `load` resolving `{ getTitle: (d) => "T:" + d.x }`) returns `"T:1"` for `{ "@patchwork": { type: "t" }, x: 1 }`. When `load` rejects, it returns `doc.title`. +- [ ] **Step 2: Run `pnpm test room`.** Expected: FAIL (module missing). +- [ ] **Step 3: Implement `room.js` and `channel.js`.** +- [ ] **Step 4: Run `pnpm test`.** Expected: PASS. +- [ ] **Step 5: Commit** with the message `call: room urls and names`. + +### Task 3: The engine, ported and re-keyed by url + +**Files:** +- Create (copied from wm, then changed): `call/src/engine/call.js`, `call/src/engine/noise.js` (imports `./lasting.js` and `../vendor/deepfilternet/deepfilternet.js`; the IndexedDB name stays the same), `call/src/engine/speaking.js`, `call/src/engine/face.js`, `call/src/vendor/deepfilternet/*` (all three files, LICENSE included) +- Test: `call/test/call.test.js` + +**Interfaces:** +- Consumes: Task 1's modules, plus `roomUrl`, `isKeyRoom`, `openChannel` and `titleOf` (Task 2). The engine never touches a `DocHandle`. Every `room.on/off("ephemeral-message")` and `room.broadcast` in wm's code goes through a `Channel`, and `event.senderId` comes from the channel's event. +- Produces (everything else that wm's `call.js` exports stays, with the same signature: `FACES`, `setNoise`, `hangUp`, `setMic`, `setCamera`, `setDevice`, `devices`, `setFloating`, `hideFloating`, `showFloating`, `canShare`, `startScreen`, `stopScreen`, `toggleStage`, `showStage`, `setFull`, `closeStage`, `onCallIn`): + - `export const KEY = "lattice-wm:call"`. + - `hearRoom(url: string, opts?: { onScreen?: boolean, name?: string }): () => void`. This normalises with `roomUrl` and keeps refcounts per url, separately for all sources and for `onScreen` sources. On the first source for a url, it opens the channel, attaches the old `hearCalls` listener, and sends `{ [KEY]: "hello" }`. A `null` channel makes it a no-op that still returns a working release. A `name` is kept while the source that gave it is held. The returned function releases this one source, and the last release detaches the listener. When `onScreen` goes from 0 to ≥1, there's no session, and `callToRejoin(url)` gives something back, it calls `forgetCall()`, then `join(url, { video: back.camera })`, and then `setMic(false)` if `!back.mic`. + - `join(url: string, opts?: { video?: boolean }): Promise`. Same body as wm's, but the room is a `Channel` from `openChannel(roomUrl(url))`, and `state.call.name` comes from `call.nameOf(url)`. When the channel is `null`, no session starts and `call.problem()` returns `"calls over a key aren't available yet"` for a key room, or `"couldn't reach that doc"` otherwise. `call.problem()` is a new accessor for a problem outside a call, cleared on the next `join`. + - `ask(url: string, opts?: { at?: { x: number, y: number } | null }): void`. This sets `asking` to `{ url, at }`. + - `holdVideoSpot(): () => void`. A refcount of frames that can show `call-video`. + - `setFaces("floating" | "sidebar")`. It's ignored for `"sidebar"` unless `call.canShowVideo()`. + - On the `call` object: + - **Removed:** `cursors`, `current`. + - **Kept:** `get`, `peers`, `stream`, `screen`, `local`, `me`, `settings`, `floatKey`, `asking`, `stopAsking`, `stage`, `full`. + - **Changed:** `find({ peer, contact })` matches by peer, else by contact, as before. `mode()` returns `"sidebar"` only when `settings.faces === "sidebar" && call.canShowVideo()`, and `"floating"` otherwise. + - **New:** `rooms()` returns `[{ url, name, others }]` for each url whose `onScreen` count is above 0, that has `onCallIn(url).length > 0`, and that isn't `state.call?.room`. `nameOf(url)` returns the held name if there is one, else the title from `titleOf` once it's resolved, else `""`. `canShowVideo()` is true when the video-spot count is above 0. `videoWanted()` is `!!state.call && call.mode() === "sidebar"`. + - Removed exports: `inProject`, `setCursors`, and `hearCalls` (it becomes internal). +- Other changes inside the file: + - Drop the `presence.js` import. + - The session's `onMessage` answers both `message[KEY] === "hello"` and `message["lattice-wm:hello?"]` with the delayed `sayHere`, as the `HELLO` branch does today. + - Drop `cursors` from `readSettings`/`saveSettings`. + - Rename the `lasting` keys to `"patchwork-call:state"`, `"patchwork-call:session"` and `"patchwork-call:view"`. + +- [ ] **Step 1: Write the failing tests.** Use a fake handle `{ url, on, off, broadcast: vi.fn(), doc: () => ({}) }` and `globalThis.repo = { find: async () => handle }`. + - `hearRoom` broadcasts `{ "lattice-wm:call": "hello" }` once, even when there are two sources. + - After an ephemeral `{ "lattice-wm:call": "here", from: "x", who: { name: "ana" } }`, `onCallIn(url)` has one entry, and `call.rooms()` lists the url only while an `onScreen` source is held. + - Releasing one of two sources keeps the listener (`off` not called), and releasing the last one calls `off`. + - `hearRoom("automerge:#h")` and `hearRoom("automerge:")` share one listener. + - `hearRoom(keyRoom(bytes))` doesn't throw, and its release doesn't either. `join(keyRoom(bytes))` leaves `call.get()` `null`, with `call.problem()` `"calls over a key aren't available yet"`. + - `nameOf(url)` returns the held name, and falls back to the title once there's no held name. + - `setFaces("sidebar")` leaves `mode()` at `"floating"` until `holdVideoSpot()` is held. After that it's `"sidebar"`, and it goes back to `"floating"` when the spot is released, with `settings.faces` still `"sidebar"`. + - Rejoin with a remembered call (`keepCall` on a stub storage, `leavingCall`) and `join` spied: an `onScreen` source triggers a join, and a source without `onScreen` doesn't. + - Mock `navigator.mediaDevices` and `RTCPeerConnection` minimally, or spy on `join`, so `join` doesn't touch media. +- [ ] **Step 2: Run `pnpm test call`.** Expected: FAIL. +- [ ] **Step 3: Copy the engine over and make the changes listed above.** +- [ ] **Step 4: Run `pnpm test`, then `pnpm build`.** Expected: PASS. Check that `dist/` contains `deepfilternet-worklet-processor.js` as an emitted asset, since the vendor loads it with `new URL(…, import.meta.url)`. If vite inlines or drops it, add it via `build.rollupOptions` or `assetsInclude` until it's emitted. +- [ ] **Step 5: Commit** with the message `call: port the engine, rooms keyed by url`. + +### Task 4: The provider and the protocol + +**Files:** +- Create: `call/src/protocol.js`, `call/src/provider.js` +- Modify: `call/src/index.js` (add the `call-provider` entry) +- Test: `call/test/provider.test.js` + +**Interfaces:** +- Consumes: `hearRoom`, `ask`, `hangUp`, `holdVideoSpot`, `call`, `onCallIn` (Task 3), `roomUrl` (Task 2). `accept` from `@inkandswitch/patchwork-providers`. +- Produces: + - `protocol.js`: `CALL_EVENT = "patchwork:call"`, `CALL = "patchwork:call"`, `CALL_ROOM = "patchwork:call-room"`, `CALL_VIDEO = "patchwork:call-video"`, and `roomState(url): { here: boolean, others: number, elsewhere: boolean }`. `here` is `call.get()?.room === url`, `others` is `onCallIn(url).length`, and `elsewhere` is `!!call.get() && !here`. + - `provider.js`: `export function CallProvider(element: HTMLElement): () => void`. On `element` it listens for: + - `patchwork:mounted` / `patchwork:unmounted`: when `"url" in detail`, it calls `hearRoom(url, { onScreen: true })` and its release, refcounted per normalised url and keyed per event. It does nothing for `componentId` details. + - `patchwork:subscribe`, three selectors, each answered with `accept`: + - `CALL {url}`: calls `hearRoom(url)` and answers with `roomState(url)` on every change. + - `CALL_ROOM {url, name}`: calls `hearRoom(url, { onScreen: true, name })` and answers the same way. + - `CALL_VIDEO`: calls `holdVideoSpot()` and answers with `{ wanted: call.videoWanted() }` on every change. + - Each teardown releases what it took. Answers come from a Solid `createRoot` + `createEffect` disposed on teardown, so only changes are posted. + - `CALL_EVENT` (detail `{ action: "ask" | "hang-up", url?, at? }`): `"ask"` calls `ask(roomUrl(url), { at })`, and `"hang-up"` calls `hangUp()`. Anything else is ignored. + - The full-window layer is added in Task 6. For now, cleanup removes every listener and releases every source. + - Entry: `{ type: "patchwork:component", id: "call-provider", name: "Call", async load() { return (await import("./provider.js")).CallProvider } }`. + +- [ ] **Step 1: Write the failing tests.** Use real `subscribe` from `@inkandswitch/patchwork-providers` against a mounted provider element, with the fake repo and handle from Task 3. + - A `patchwork:mounted` with `{ url, toolId }` dispatched from a grandchild of the provider element (bubbling, composed) makes `hearRoom` hear it. Check with a `here` message, then `call.rooms()`. + - Mounting the same url twice and unmounting once still hears it, and the second unmount stops it. + - Mounting `url#heads` and unmounting the bare `url` balances out. + - `subscribe(el, { type: "patchwork:call", url })` first gets `{ here: false, others: 0, elsewhere: false }`, then `others: 1` after a `here` message. + - `subscribe(el, { type: "patchwork:call-room", url, name: "proj" })` makes `call.nameOf(url)` return `"proj"` and `call.rooms()` list the room. Unsubscribing removes both. + - `subscribe(el, { type: "patchwork:call-video" })` gets `{ wanted: false }`, and while it's held, `call.canShowVideo()` is true. + - Dispatching `new CustomEvent("patchwork:call", { bubbles: true, detail: { action: "ask", url, at: { x: 1, y: 2 } } })` makes `call.asking()` deep-equal `{ url, at: { x: 1, y: 2 } }`. + - After cleanup, a further `patchwork:mounted` is ignored. +- [ ] **Step 2: Run `pnpm test provider`.** Expected: FAIL. +- [ ] **Step 3: Implement `protocol.js`, `provider.js` and the entry.** +- [ ] **Step 4: Run `pnpm test && pnpm build && pnpm lint`.** Expected: PASS. In `dist/index.js` (the entry chunk), check that there is no bare `import` of `solid-js` or `@inkandswitch/*` at top level. +- [ ] **Step 5: Commit** with the message `call: provider and protocol`. + +### Task 5: UI pieces, styles, and `call-face` + +**Files:** +- Create: `call/src/ui/icons.js` (only `close`, `mic`, `mic-off`, `minimise`, `screen`, `video`, `video-off`, `phone`, `hang-up`, `chevron-down`, and `check` if used), `call/src/ui/along.js`, `call/src/ui/arriving.js`, `call/src/ui/colour.js` (`colourFor` from `pointer.js`), `call/src/ui/avatar.js` (`faceImage` from `presence.js`, with the same lazy `patchwork-filesystem` import), `call/src/ui/faces.js` (`names`, `Face`, `Screen`, `DragOut`, `StageButton`, `initial`, `videoOf`, copied from `wm/call/ui.js`), `call/src/ui/styles.css`, `call/src/call-face.js` +- Modify: `call/src/index.js` (add the `call-face` entry) +- Test: `call/test/faces.test.js` + +**Interfaces:** +- Consumes: `call`, `centreOn`, `hearing` and `wiggle` from the engine. +- Produces: + - `Face(props: { id: "me" | string, size?: number /* px */, shape?: "round" | "tile", muted?: boolean, title?: boolean })`. Same rendering as wm's, but the size goes to `--call-face-size: px` (no `--lattice-px`), and every class `lattice-wm-call-*` becomes `patchwork-call-*`. + - `call-face.js`: `export function CallFace(element: HTMLElement): () => void`. It reads the `peer`, `contact`, `size` (px number) and `no-title` attributes, re-reading them through a `MutationObserver` on those four. It renders `Face` for `call.find({ peer, contact })?.id`, and nothing when there's no match. + - `styles.css`: the call rules from `wm.css` and `shell.css`, renamed (`lattice-wm-call-*` and `lattice-call-*` to `patchwork-call-*`, `lattice-wm-menu*` to `patchwork-call-menu*`), with every `--lattice-*` colour or font replaced by a `--call-*` token derived in the `@layer package` block, per Global Constraints. + - Entry: `{ type: "patchwork:component", id: "call-face", name: "Call face", async load() { return (await import("./call-face.js")).CallFace } }`. + +- [ ] **Step 1: Write the failing tests.** `names(["a"])` returns `"a"`, `names(["a","b","c"])` matches wm's output, and so on, copying wm's expectations. Then `CallFace` on an element with `contact="automerge:c1"`: + - With no call, it renders no `.patchwork-call-face`. + - With a call whose `state.peers` has a peer with `contact: "automerge:c1"` (seeded through an exported test hook or the `here` path from Task 3's fake room), it renders one, with `--call-face-size` set to `44px` when `size="44"`, and with no `title` attribute when `no-title` is present. + - Changing the `contact` attribute to someone absent removes the face. +- [ ] **Step 2: Run `pnpm test faces`.** Expected: FAIL. +- [ ] **Step 3: Implement the files and the entry.** +- [ ] **Step 4: Run `pnpm test && pnpm build`.** Expected: PASS. Grep `src/` for `lattice-` and expect it only in the wire, settings and IndexedDB keys. +- [ ] **Step 5: Commit** with the message `call: faces, styles, call-face`. + +### Task 6: The floating layer: question, floating faces, stage + +**Files:** +- Create: `call/src/ui/question.js` (`CallQuestion`, from `CallPrompt`), `call/src/ui/floating.js` (`CallFloating`), `call/src/ui/stage.js` (`CallStage`), `call/src/ui/area.js` +- Modify: `call/src/provider.js` (render the layer into `element`) +- Test: `call/test/layer.test.js` + +**Interfaces:** +- Consumes: Task 5's `Face`/`Screen`/`DragOut`/`StageButton`, and the engine. +- Produces: + - `area.js`: `stageArea(root: HTMLElement): Accessor`. It tracks the first `[data-call-stage]` under `root`, using a `MutationObserver` on `root` for it appearing or going, plus a `ResizeObserver` on it and window `resize`. It's `null` when there's none. + - `CallQuestion()`: shows only while `call.asking()` is set, as a `popover="auto"` placed at `asking().at` or else `{ x: 16, y: 16 }`, clamped as in wm. Its copy is wm's: "start a call here?" or " on a call", then "share your video?", then "video on" / "just voice", then "not now". The foot reads "leaves the call in " when you're in a call elsewhere, using `call.nameOf(thatRoom) || "another doc"`. Pressing a button calls `join(asking().url, { video })`. There's no arrival or `focus` logic. It focuses its first button when it opens. + - `CallFloating(props: { area: Accessor })` and `CallStage(props: { area: Accessor })`. These are wm's, except that the start position and box come from `area()` and fall back to the window, and both use `popover="manual"` so they sit in the top layer. + - The provider mounts `
` with the three inside it (Solid `render`), and disposes it in cleanup. + +- [ ] **Step 1: Write the failing tests.** + - With no asking, no `.patchwork-call-question` is open. + - After `ask(url, { at: { x: 40, y: 50 } })`, it's open with `style.left === "40px"`, and with no call on, it says "start a call here?". + - Clicking "just voice" calls a spied `join` with `(url, { video: false })` and clears `call.asking()`. + - While in a call on another url with a held name `"other"`, the foot says "leaves the call in other". + - `stageArea` returns the rect of an element given `data-call-stage` after it's added (with a stubbed `getBoundingClientRect`), and `null` after it's removed. +- [ ] **Step 2: Run `pnpm test layer`.** Expected: FAIL. +- [ ] **Step 3: Implement the files and wire them into the provider.** +- [ ] **Step 4: Run `pnpm test && pnpm build`.** Expected: PASS. +- [ ] **Step 5: Commit** with the message `call: question, floating faces and stage`. + +### Task 7: The control panel and the video tab + +**Files:** +- Create: `call/src/control-panel.js` (from `lattice/call-bar.js`), `call/src/video.js` (`CallTiles` from `wm/call/ui.js`) +- Modify: `call/src/index.js` (two entries) +- Test: `call/test/control-panel.test.js`, `call/test/video.test.js` + +**Interfaces:** +- Consumes: the engine, and Task 5's UI pieces. +- Produces: + - `export function CallControlPanel(element: HTMLElement): () => void`. + - It sets `element.dataset.sidebarSize` to `"none"` when `!call.get() && call.rooms().length === 0`, and to `"fit"` otherwise. + - **Not in a call:** it lists `call.rooms()`, one row each, showing the room's name (or "a doc"), " on a call", and join buttons "video on" / "just voice" that call `join(url, { video })`. When you're in another call, that row also says "leaves the call in ". + - **Going to the call's doc:** the room's name, both in a listed row and in the in-call head ("where the call is"), is a button. Pressing it calls `openDocument(element, url)` from `@inkandswitch/patchwork-elements`, which dispatches `patchwork:open-document` (bubbling, composed), so whatever frame it's in opens the doc. A frame whose room isn't a doc it opens directly, like lattice's project folder, can intercept that event. `@inkandswitch/patchwork-elements` goes in `package.json` at comments-view's version. It's on the importmap, so it stays external. + - **In a call:** the `In` section of `call-bar.js`, renamed to `patchwork-call-panel-*`. The menu has faces "floating" / "in the sidebar", with "in the sidebar" listed only while `call.canShowVideo()`. Then mic and camera devices, then noise. There's no "on cursors" item. + - `export function CallVideo(element: HTMLElement): () => void`: `CallTiles` while you're in a call, else `

no call

`. + - Entries: + - `{ type: "patchwork:component", id: "call-control-panel", name: "Call", async load() { return (await import("./control-panel.js")).CallControlPanel } }` + - `{ type: "patchwork:component", id: "call-video", tags: ["context-tool"], name: "Call", icon: "Video", async load() { return (await import("./video.js")).CallVideo } }` + +- [ ] **Step 1: Write the failing tests.** + - The panel's `element.dataset.sidebarSize` is `"none"` at rest, becomes `"fit"` after a `here` message on an `onScreen` room, and goes back to `"none"` after that room is released. + - The row shows the held name, and "video on" calls `join(url, { video: true })`. + - Clicking the row's name dispatches a `patchwork:open-document` event with `detail.url === url` from inside the panel's element, caught by a listener on its parent. Clicking the in-call head's name does the same with the call's room url. + - With a call on and the menu opened, there's no "in the sidebar" item until `holdVideoSpot()` is held, and no item containing "cursor" at all. + - `CallVideo` shows "no call" out of a call. +- [ ] **Step 2: Run `pnpm test control-panel video`.** Expected: FAIL. +- [ ] **Step 3: Implement the two components and their entries.** +- [ ] **Step 4: Run `pnpm test && pnpm build`.** Expected: PASS. +- [ ] **Step 5: Commit** with the message `call: control panel and video tab`. + +### Task 8: The doc-title button + +**Files:** +- Create: `call/src/button.js` +- Modify: `call/src/index.js` +- Test: `call/test/button.test.js` + +**Interfaces:** +- Consumes: only `protocol.js` constants and `subscribe` from `@inkandswitch/patchwork-providers`. **No engine import** (spec: it has to keep working from another realm). +- Produces: + - `export default function CallDoctitleButton(handle: DocHandle, element: HTMLElement): () => void`. + - It subscribes to `{ type: CALL, url: handle.url }`, and renders nothing until the first answer arrives. + - It then renders a ` + +
+
+ ${() => (elsewhere() ? `leaves the call in ${elsewhere().name || "another project"}` : "")} + +
+ `; +} + +// a floating face's width, to start with, and as small and big as it goes +const FLOAT_MIN = 72; +const FLOAT_MAX = 640; +// to start with, everyone else bigger than you +const THEIR_WIDTH = 200; +const MY_WIDTH = 120; + +// a little, but always the same little, for `key`: -1 to 1 +function nudge(key) { + let hash = 0; + for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) | 0; + return ((Math.abs(hash) % 1000) / 1000) * 2 - 1; +} + +// where a floating face starts (fractions of the window): you in the bottom +// right, and everyone else (`index`: which of them) at the left of `panes` +// (a box: where they are), near the call, a little way down, two across and +// a few down, each nudged out of line a little +function startAt(index, key, panes) { + if (key === "me") { + return { + x: 1 - (MY_WIDTH + 24) / innerWidth, + y: 1 - (MY_WIDTH * 0.75 + 56) / innerHeight, + width: MY_WIDTH, + }; + } + const column = index % 2; + const row = Math.floor(index / 2); + const area = panes?.width ? panes : { left: innerWidth * 0.25, top: 0, height: innerHeight }; + const left = area.left + 40 + column * (THEIR_WIDTH + 32) + (row % 2) * 48 + nudge(`${key}x`) * 20; + const top = area.top + area.height * 0.24 + row * (THEIR_WIDTH * 0.75 + 56) + nudge(`${key}y`) * 20; + return { x: left / innerWidth, y: top / innerHeight, width: THEIR_WIDTH }; +} + +// where a floating face goes: where it was put, but all of it in the window +// (a face sized bigger near an edge moves away from it) +function inWindow({ x, y, width }) { + const height = width * 0.75 + 24; + return { + left: `${Math.max(0, Math.min(x * innerWidth, innerWidth - width - 8))}px`, + top: `${Math.max(0, Math.min(y * innerHeight, innerHeight - height - 8))}px`, + }; +} + +// ── a floating face going to the call bar, and coming out of it ───────── + +// on screen, moving: in and out. coming in: out +const EASE_MOVE = "cubic-bezier(0.77, 0, 0.175, 1)"; +const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)"; +const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches; + +// someone's face in the call bar: where their floating face goes to, and +// comes from. scrolled along out of sight, it's brought back into it first +function inBar(id) { + const face = document.querySelector(`.patchwork-call-panel [data-call-face="${CSS.escape(id)}"]`); + bringAlong(face); + return face; +} + +// the transform that puts `float`'s picture over `target`, and the origin it's +// from (the picture's middle), or null if either isn't on screen +function towards(float, target) { + const picture = float.querySelector(".patchwork-call-float-picture")?.getBoundingClientRect(); + const box = float.getBoundingClientRect(); + const to = target?.getBoundingClientRect(); + if (!picture?.width || !to?.width) return null; + const x = picture.left + picture.width / 2; + const y = picture.top + picture.height / 2; + return { + origin: `${x - box.left}px ${y - box.top}px`, + transform: `translate(${to.left + to.width / 2 - x}px, ${to.top + to.height / 2 - y}px) scale(${to.width / picture.width})`, + }; +} + +// put away: it goes into their face in the call bar, then it's gone +function minimise(float, id, done) { + const to = reducedMotion() ? null : towards(float, inBar(id)); + float.style.pointerEvents = "none"; + const motion = to + ? float.animate( + [ + { transformOrigin: to.origin, transform: "none", opacity: 1 }, + { transformOrigin: to.origin, transform: to.transform, opacity: 0.3 }, + ], + { duration: 240, easing: EASE_MOVE, fill: "forwards" }, + ) + : float.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, easing: "ease-out", fill: "forwards" }); + motion.finished.then(done, done); +} + +// the ones just dragged out of the call bar: they're where they were let go, +// not coming out of it +const dropped = new Set(); + +// arriving (you've joined, they have, or faces are floating now): out of +// their face in the call bar to where it goes, one after another +function arrive(float, id, key, index) { + if (dropped.delete(key) || reducedMotion()) { + float.animate([{ opacity: 0, scale: 0.95 }, { opacity: 1, scale: 1 }], { duration: 160, easing: EASE_OUT }); + return; + } + const from = towards(float, inBar(id)); + if (!from) { + float.animate([{ opacity: 0, scale: 0.95 }, { opacity: 1, scale: 1 }], { duration: 200, easing: EASE_OUT }); + return; + } + float.animate( + [ + { transformOrigin: from.origin, transform: from.transform, opacity: 0.3 }, + { transformOrigin: from.origin, transform: "none", opacity: 1 }, + ], + { duration: 280, delay: Math.min(index, 8) * 40, easing: EASE_OUT, fill: "backwards" }, + ); +} + +// ── the wiggle round a floating face, while they're speaking ─────────── + +const WIGGLE_POINTS = 160; +// how much of their sound goes round (of the analyser's 2048: about 20ms) +const WIGGLE_SPAN = 1024; +// how far past the picture it can go (the canvas is that much bigger) +const WIGGLE_PAD = 32; +// a face's border (wm.css): the line that wiggles +const FACE_BORDER = 2; +// a floating face's corners, as a picture (wm.css) +const TILE_ROUND = 10; +let stillMotion = null; + +/** + * their sound's waveform, all the way round their floating face (in its + * picture's box): its border, bumping out from where it is, as soon as + * there's sound, and while `props.speaking`. behind the face, so still it's + * the border. not with reduced motion (the ring's shown then, as elsewhere) + */ +function Wiggle(props) { + const canvas = document.createElement("canvas"); + canvas.className = "patchwork-call-wiggle"; + canvas.setAttribute("aria-hidden", "true"); + stillMotion ??= matchMedia("(prefers-reduced-motion: reduce)"); + let frame = requestAnimationFrame(draw); + onCleanup(() => cancelAnimationFrame(frame)); + // how much it's shown (0 to 1), and whether there's anything to clear + let shown = 0; + let drawn = false; + let samples = null; + let loudest = 0; + let heardAt = -Infinity; + let colour = null; + let colourAt = -Infinity; + + function draw(now) { + frame = requestAnimationFrame(draw); + const analyser = !stillMotion.matches && hearing(props.id); + if (analyser) { + if (samples?.length !== analyser.fftSize) samples = new Float32Array(analyser.fftSize); + analyser.getFloatTimeDomainData(samples); + let sum = 0; + for (const sample of samples) sum += sample * sample; + if (Math.sqrt(sum / samples.length) >= WIGGLE_HEARD) heardAt = now; + } + const wanted = !!analyser && (now - heardAt < WIGGLE_HOLD || !!props.speaking); + shown += ((wanted ? 1 : 0) - shown) * (wanted ? 0.6 : 0.3); + if (shown < 0.01 && !drawn) return; + const picture = canvas.parentElement; + const face = picture?.querySelector(".patchwork-call-face"); + if (!face) return; + const g = canvas.getContext("2d"); + const dpr = devicePixelRatio || 1; + const width = picture.offsetWidth + 2 * WIGGLE_PAD; + const height = picture.offsetHeight + 2 * WIGGLE_PAD; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + g.setTransform(dpr, 0, 0, dpr, 0, 0); + g.clearRect(0, 0, width, height); + drawn = false; + if (shown < 0.01 || !analyser) return; + + const heard = louder(ringWave(samples, WIGGLE_POINTS, WIGGLE_SPAN), loudest); + loudest = heard.loudest; + // the face's box, and its corners: between a picture's and a circle's + // as its width goes from the whole picture to round (wm.css) + const w = face.offsetWidth; + const h = face.offsetHeight; + const round = Math.max(0, Math.min(1, (picture.offsetWidth - w) / (picture.offsetWidth * 0.25))); + const r = TILE_ROUND + (Math.min(w, h) / 2 - TILE_ROUND) * round; + // out from the middle of the border, either way of the sound as out + const swing = Math.max(4, Math.min(9, Math.min(w, h) * 0.07)) * shown; + const points = wiggle(w, h, r, heard.values.map(Math.abs), -FACE_BORDER / 2, swing); + + if (now - colourAt > 500) { + colour = getComputedStyle(face).borderTopColor; + colourAt = now; + } + g.translate(WIGGLE_PAD + face.offsetLeft + w / 2, WIGGLE_PAD + face.offsetTop + h / 2); + g.beginPath(); + g.moveTo(...points[0]); + for (const point of points) g.lineTo(...point); + g.closePath(); + g.strokeStyle = colour; + g.globalAlpha = shown; + g.lineWidth = FACE_BORDER; + g.lineJoin = "round"; + g.stroke(); + g.globalAlpha = 1; + drawn = true; + } + return canvas; +} + +/** + * everyone's faces, floating over everything, each where you put it and the + * size you made it (to start with, near `props.panes`, an element): dragged to move, and by its corner to size. each is a + * circle centred on their face, or, double clicked, the picture's own shape + * (4:3), none of it cut off (and double clicked again, a circle) + */ +export function CallFloating(props) { + // you too, last: by id, so each keeps its place as people come and go. + // not the ones put away (dragged out of the call bar, they're back) + const ids = createMemo(() => + [...call.peers().map((peer) => peer.id), "me"].filter((id) => !call.settings.floating[call.floatKey(id)]?.hidden), + ); + return html`<${Show} when=${() => call.get() && call.mode() === "floating"}> +
+ <${For} each=${ids}> + ${(id, index) => { + const mine = id === "me"; + const peer = () => (mine ? null : call.peers().find((peer) => peer.id === id)); + const key = () => call.floatKey(id); + const name = () => (mine ? "you" : peer()?.name); + const muted = () => (mine ? !call.get()?.mic : !peer()?.mic); + const at = () => ({ ...startAt(index(), key(), props.panes?.getBoundingClientRect()), ...call.settings.floating[key()] }); + let float; + // what's being done to it: {sizing, dx, dy (or the width it was), at} + let dragging = null; + function onPointerDown(event) { + if (event.button !== 0 || event.target.closest(".patchwork-call-float-close")) return; + const box = float.getBoundingClientRect(); + const sizing = !!event.target.closest(".patchwork-call-size"); + dragging = sizing + ? { sizing, x: event.clientX, width: at().width, at: at() } + : { sizing, dx: event.clientX - box.left, dy: event.clientY - box.top, at: at() }; + float.setPointerCapture(event.pointerId); + event.preventDefault(); + } + function onPointerMove(event) { + if (!dragging) return; + if (dragging.sizing) { + const width = Math.round(Math.max(FLOAT_MIN, Math.min(FLOAT_MAX, dragging.width + event.clientX - dragging.x))); + dragging.at = { ...dragging.at, width }; + Object.assign(float.style, { ...inWindow(dragging.at), width: `${width}px` }); + return; + } + const x = Math.max(0, Math.min(1, (event.clientX - dragging.dx) / innerWidth)); + const y = Math.max(0, Math.min(1, (event.clientY - dragging.dy) / innerHeight)); + dragging.at = { ...dragging.at, x, y }; + Object.assign(float.style, inWindow(dragging.at)); + } + function onPointerUp() { + if (dragging) setFloating(key(), dragging.at); + dragging = null; + } + const shape = () => (at().shape === "tile" ? "tile" : "round"); + return html`
(call.stage() && (mine || call.stage() === id) ? "" : undefined)} + onDblClick=${() => setFloating(key(), { ...at(), shape: shape() === "round" ? "tile" : "round" })} + ref=${(el) => { + float = el; + // once it's where it goes + requestAnimationFrame(() => float.isConnected && arrive(float, id, key(), index())); + }} + style=${() => ({ ...inWindow(at()), width: `${at().width}px` })} + onPointerDown=${onPointerDown} + onPointerMove=${onPointerMove} + onPointerUp=${onPointerUp} + onPointerCancel=${onPointerUp} + > + + <${Wiggle} id=${id} speaking=${() => (mine ? call.get()?.speaking : peer()?.speaking)} /> + <${Face} id=${id} shape="tile" muted=${false} /> + + + + + ${name} + <${Show} when=${muted}> + ${icon("mic-off", 10)} + + +
`; + }} + +
+ `; +} + +// a face dragged out (of the call bar) and let go somewhere else: their +// floating face is back, there. only while faces are floating. from the +// press, the whole window's listened to (a little face is soon left behind +// by the pointer), and the browser's own dragging is kept out of it. +// returns the handler for the press, and whether what just happened was a +// drag (so it's not a click too) +const DRAG_FROM = 5; +const GHOST_SIZE = 56; +function dragOut(id) { + let dragged = false; + function onPointerDown(event) { + if (event.button !== 0 || call.mode() !== "floating") return; + const from = event.target.closest("[data-call-face]"); + if (!from) return; + // no text selected, no picture dragged by the browser + event.preventDefault(); + dragged = false; + const start = { x: event.clientX, y: event.clientY }; + let ghost = null; + const move = (moved) => { + if (moved.pointerId !== event.pointerId) return; + if (!ghost) { + if (Math.hypot(moved.clientX - start.x, moved.clientY - start.y) < DRAG_FROM) return; + ghost = ghostOf(from.querySelector(".patchwork-call-face")); + } + ghost.style.left = `${moved.clientX}px`; + ghost.style.top = `${moved.clientY}px`; + }; + const stop = () => { + removeEventListener("pointermove", move, true); + removeEventListener("pointerup", up, true); + removeEventListener("pointercancel", cancel, true); + ghost?.remove(); + }; + const cancel = (ended) => { + if (ended.pointerId === event.pointerId) stop(); + }; + const up = (ended) => { + if (ended.pointerId !== event.pointerId) return; + const was = !!ghost; + stop(); + if (!was) return; + dragged = true; + // let go back over the call bar: never mind + if (from.closest(".patchwork-call-panel")?.contains(document.elementFromPoint(ended.clientX, ended.clientY))) return; + const key = call.floatKey(id); + const width = call.settings.floating[key]?.width ?? (key === "me" ? MY_WIDTH : THEIR_WIDTH); + dropped.add(key); + showFloating(key, { + x: Math.max(0, (ended.clientX - width / 2) / innerWidth), + y: Math.max(0, (ended.clientY - (width * 0.75) / 2) / innerHeight), + width, + }); + }; + addEventListener("pointermove", move, true); + addEventListener("pointerup", up, true); + addEventListener("pointercancel", cancel, true); + } + return { + onPointerDown, + // (once: the click after a drag) + wasDrag: () => { + const was = dragged; + dragged = false; + return was; + }, + }; +} + +// what's dragged: a copy of the face, pictures and all, under the pointer. +// its look is its own, so nothing it's dragged over or out of shapes it +function ghostOf(face) { + const ghost = face.cloneNode(true); + const video = face.querySelector("video"); + const copy = ghost.querySelector("video"); + if (video && copy) { + copy.srcObject = video.srcObject; + copy.muted = true; + copy.play().catch(() => {}); + } + ghost.classList.add("patchwork-call-ghost"); + Object.assign(ghost.style, { + position: "fixed", + zIndex: "2147483000", + width: `${GHOST_SIZE}px`, + height: `${GHOST_SIZE}px`, + translate: "-50% -50%", + pointerEvents: "none", + margin: "0", + }); + ghost.style.setProperty("--call-face-size", `${GHOST_SIZE}px`); + document.body.append(ghost); + return ghost; +} + +// someone minimised (floating, put away): their floating face back where it +// was, out of the call bar. whether they were +function restore(id) { + const key = call.floatKey(id); + if (call.mode() !== "floating" || !call.settings.floating[key]?.hidden) return false; + showFloating(key, {}); + return true; +} + +/** + * `props.id`'s face (props.children), to drag out of where it is into a + * floating face, or, minimised, click to have it back + */ +export function DragOut(props) { + const drag = dragOut(props.id); + return html` props.id} + title=${() => (call.mode() === "floating" ? "drag out to float (minimised: click to have it back)" : undefined)} + onPointerDown=${drag.onPointerDown} + onDragStart=${(event) => event.preventDefault()} + onClick=${() => !drag.wasDrag() && restore(props.id)} + > + ${props.children} + `; +} + +/** + * a face to click to fill the panes with them (again: the panes back), or, + * floating, drag out to float, and minimised, click to have back where it + * was. `props.id`; the rest's what's in it + */ +export function StageButton(props) { + const drag = dragOut(props.id); + return html``; +} + +/** + * everyone's faces, for a sidebar, yours last: big while there are few, two + * across when there are more, and past that a list, whoever spoke last at + * the top. one clicked fills the box, you in its corner; clicked again, all + * of them + */ +export function CallTiles() { + const [focus, setFocus] = createSignal(null); + const focused = () => (focus() && call.peers().some((peer) => peer.id === focus()) ? focus() : null); + const count = () => call.peers().length + 1; + const layout = () => tilesFor(count()); + const peers = createMemo(() => { + const list = call.peers(); + if (layout() !== "chips") return list; + return [...list].sort((a, b) => (b.speaking ? 1 : 0) - (a.speaking ? 1 : 0) || (b.spoke ?? 0) - (a.spoke ?? 0)); + }); + const name = (id) => call.peers().find((peer) => peer.id === id)?.name; + + const Focused = () => html`
+ +
`; + + return html`<${Show} when=${focused} fallback=${html`
+ <${For} each=${peers}> + ${(peer) => html``} + + <${Show} when=${() => call.get()}> +
+ <${Show} + when=${() => layout() !== "chips"} + fallback=${html`<${Face} id="me" size=${28} title=${false} />`} + > + <${Face} id="me" shape="tile" title=${false} /> + + you +
+ +
`}> + ${Focused} + `; +} + +/** + * whoever's been clicked (call.stage()), filling `props.box` (a style: where + * the panes are), with you in its corner. double clicked, it fills the + * screen, you a little bigger; escape, or its ×, puts the panes back + */ +export function CallStage(props) { + let stage; + const id = () => call.stage(); + const person = () => call.peers().find((peer) => peer.id === id()); + + // the whole screen, while it's wanted, and not once it's left (escape) + createEffect(() => { + // (read first: it's what this runs again for) + const full = call.full(); + id(); + if (!stage?.isConnected) return; + if (full && document.fullscreenElement !== stage) { + stage.requestFullscreen?.().catch(() => setFull(false)); + } else if (!full && document.fullscreenElement === stage) { + document.exitFullscreen().catch(() => {}); + } + }); + const onFullscreen = () => { + if (call.full() && document.fullscreenElement !== stage) setFull(false); + }; + const onKey = (event) => { + if (event.key === "Escape" && id() && !document.fullscreenElement) closeStage(); + }; + document.addEventListener("fullscreenchange", onFullscreen); + document.addEventListener("keydown", onKey); + onCleanup(() => { + document.removeEventListener("fullscreenchange", onFullscreen); + document.removeEventListener("keydown", onKey); + if (stage && document.fullscreenElement === stage) document.exitFullscreen().catch(() => {}); + }); + + return html`<${Show} when=${id}> +
(stage = el)} + style=${() => props.box ?? {}} + onDblClick=${() => setFull(!call.full())} + title="double click: the whole screen" + > + <${Show} when=${id} keyed> + ${(staged) => html`<${Picture} id=${staged} />`} + + + ${() => (call.screen(id()) ? `${person()?.name}'s screen` : person()?.name)} + <${Show} when=${() => person() && !person().mic}>${icon("mic-off", 12)} + + + + <${Face} id="me" shape="tile" title=${false} /> + +
+ `; +} diff --git a/call/test/faces.test.js b/call/test/faces.test.js new file mode 100644 index 00000000..3db6e0ef --- /dev/null +++ b/call/test/faces.test.js @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createSignal } from "solid-js"; + +// the engine, as far as a face reads it: who's in the call, and finding them +const [peers, setPeers] = createSignal([]); +const [inCall, setInCall] = createSignal(null); +vi.mock("../src/engine/call.js", () => { + const call = { + get: () => inCall(), + peers: () => peers(), + me: () => ({ name: "me", contact: "", colour: "", avatar: "" }), + local: () => null, + stream: () => null, + screen: () => null, + mode: () => "floating", + stage: () => null, + full: () => false, + asking: () => null, + stopAsking: () => {}, + nameOf: () => "", + settings: { floating: {} }, + floatKey: (id) => id, + find({ peer, contact }) { + return ( + peers().find((p) => peer && p.peer === peer) ?? peers().find((p) => contact && p.contact === contact) ?? null + ); + }, + }; + const nothing = () => {}; + return { + call, + onCallIn: () => [], + join: nothing, + setFloating: nothing, + hideFloating: nothing, + showFloating: nothing, + toggleStage: nothing, + setFull: nothing, + closeStage: nothing, + }; +}); + +const { names } = await import("../src/ui/ui.js"); +const { CallFace } = await import("../src/call-face.js"); + +describe("names", () => { + test("one, two, or more of them", () => { + expect(names([])).toBe(""); + expect(names(["chee"])).toBe("chee"); + expect(names(["chee", "mimi"])).toBe("chee and mimi"); + expect(names(["chee", "mimi", "agnes"])).toBe("chee, mimi and agnes"); + }); +}); + +describe("call-face", () => { + const ana = { id: "p1", peer: "tab-1", contact: "automerge:c1", name: "ana", colour: "", avatar: "", mic: true }; + let element, stop; + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + beforeEach(() => { + setPeers([]); + setInCall(null); + element = document.createElement("div"); + document.body.append(element); + }); + afterEach(() => { + stop?.(); + element.remove(); + }); + + test("nobody's face, out of a call", () => { + element.setAttribute("contact", "automerge:c1"); + stop = CallFace(element); + expect(element.querySelector(".patchwork-call-face")).toBe(null); + }); + + test("their face while they're in your call, by who they are", () => { + setInCall({ room: "automerge:r", mic: true }); + setPeers([ana]); + element.setAttribute("contact", "automerge:c1"); + element.setAttribute("size", "44"); + stop = CallFace(element); + const face = element.querySelector(".patchwork-call-face"); + expect(face).not.toBe(null); + expect(face.style.getPropertyValue("--call-face-size")).toBe("44px"); + expect(face.getAttribute("title")).toBe("ana"); + }); + + test("by the tab they're in, too, and without their name when asked", () => { + setInCall({ room: "automerge:r", mic: true }); + setPeers([ana]); + element.setAttribute("peer", "tab-1"); + element.setAttribute("no-title", ""); + stop = CallFace(element); + expect(element.querySelector(".patchwork-call-face").hasAttribute("title")).toBe(false); + }); + + test("someone else asked for: gone", async () => { + setInCall({ room: "automerge:r", mic: true }); + setPeers([ana]); + element.setAttribute("contact", "automerge:c1"); + stop = CallFace(element); + expect(element.querySelector(".patchwork-call-face")).not.toBe(null); + element.setAttribute("contact", "automerge:nobody"); + await flush(); + expect(element.querySelector(".patchwork-call-face")).toBe(null); + }); + + test("stopped, it's gone", () => { + setInCall({ room: "automerge:r", mic: true }); + setPeers([ana]); + element.setAttribute("contact", "automerge:c1"); + CallFace(element)(); + expect(element.querySelector(".patchwork-call-face")).toBe(null); + }); +}); From ca6df887c3d12107780eab25fe4d27099370bda1 Mon Sep 17 00:00:00 2001 From: chee Date: Fri, 13 Nov 2026 01:14:35 +0000 Subject: [PATCH 07/25] call: question, floating faces and stage Co-Authored-By: Claude Opus 5.5 --- call/src/provider.js | 17 ++++++ call/src/ui/area.js | 45 +++++++++++++++ call/src/ui/styles.css | 80 ++++++++++++++++++-------- call/src/ui/ui.js | 83 +++++++++++++++++---------- call/test/layer.test.js | 111 +++++++++++++++++++++++++++++++++++++ call/test/provider.test.js | 13 +++++ 6 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 call/src/ui/area.js create mode 100644 call/test/layer.test.js diff --git a/call/src/provider.js b/call/src/provider.js index ffec169b..48aeaa1c 100644 --- a/call/src/provider.js +++ b/call/src/provider.js @@ -2,10 +2,15 @@ // it hears every doc mounted inside it, answers the protocol (protocol.js), // and draws what floats import { createEffect, createRoot } from "solid-js"; +import { render } from "solid-js/web"; +import html from "solid-js/html"; import { accept } from "@inkandswitch/patchwork-providers"; import { ask, call, hangUp, hearRoom, holdVideoSpot } from "./engine/call.js"; import { roomUrl } from "./engine/room.js"; import { CALL, CALL_EVENT, CALL_ROOM, CALL_VIDEO, roomState } from "./protocol.js"; +import { CallFloating, CallQuestion, CallStage } from "./ui/ui.js"; +import { stageArea } from "./ui/area.js"; +import "./ui/styles.css"; /** answer `event` with `read()` now and whenever it changes, till it's unsubscribed */ function answer(event, read, hold) { @@ -63,6 +68,16 @@ export function CallProvider(element) { else if (action === "hang-up") hangUp(); } + // what floats: the question, faces, and whoever's on the stage, in a layer + // of their own (each its own popover, over the frame) + const layer = document.createElement("div"); + layer.className = "patchwork-call"; + element.append(layer); + const unrender = render(() => { + const area = stageArea(element); + return html`<${CallQuestion} /><${CallFloating} area=${area} /><${CallStage} area=${area} />`; + }, layer); + element.addEventListener("patchwork:mounted", onMounted); element.addEventListener("patchwork:unmounted", onUnmounted); element.addEventListener("patchwork:subscribe", onSubscribe); @@ -75,5 +90,7 @@ export function CallProvider(element) { element.removeEventListener(CALL_EVENT, onCall); for (const held of mounts.values()) for (const release of held) release(); mounts.clear(); + unrender(); + layer.remove(); }; } diff --git a/call/src/ui/area.js b/call/src/ui/area.js new file mode 100644 index 00000000..e7ca7026 --- /dev/null +++ b/call/src/ui/area.js @@ -0,0 +1,45 @@ +// where the stage goes: the first element under the provider a frame has +// marked data-call-stage (threepane's main area, lattice's panes), as a box +// on screen, followed as it moves and changes size. null where there's none: +// the stage fills the window then +import { createSignal, onCleanup } from "solid-js"; + +const same = (a, b) => + a === b || (!!a && !!b && a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height); + +/** the box of the stage's area under `root`, or null (in a reactive scope: it's let go on cleanup) */ +export function stageArea(root) { + const [box, setBox] = createSignal(null, { equals: same }); + let marked = null; + const sizes = new ResizeObserver(() => measure()); + + function measure() { + if (!marked?.isConnected) return setBox(null); + const { left, top, width, height } = marked.getBoundingClientRect(); + setBox({ left, top, width, height }); + } + + function find() { + const now = root.querySelector("[data-call-stage]"); + if (now !== marked) { + if (marked) sizes.unobserve(marked); + marked = now; + if (marked) sizes.observe(marked); + } + measure(); + } + + const changes = new MutationObserver(find); + changes.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-call-stage"] }); + addEventListener("resize", measure); + addEventListener("scroll", measure, true); + find(); + + onCleanup(() => { + changes.disconnect(); + sizes.disconnect(); + removeEventListener("resize", measure); + removeEventListener("scroll", measure, true); + }); + return box; +} diff --git a/call/src/ui/styles.css b/call/src/ui/styles.css index ce9558ef..b8ebfba1 100644 --- a/call/src/ui/styles.css +++ b/call/src/ui/styles.css @@ -37,6 +37,17 @@ --call-round: var(--studio-radius-round); --call-sans: var(--studio-family-ui); --call-sheet: var(--editor-fill); + + /* on video, which is dark whatever the theme: the stage behind a + picture, and what's written over one */ + --call-stage-fill: oklch(0.18 0 0); + --call-stage-ink: oklch(0.98 0 0); + --call-on-video-ink: oklch(1 0 0 / 0.9); + --call-on-video-shadow: oklch(0 0 0 / 0.6); + --call-on-video-scrim: oklch(0 0 0 / 0.55); + /* faces floating over everything */ + --call-float-shadow: oklch(0 0 0 / 0.45); + --call-float-shadow-soft: oklch(0 0 0 / 0.35); } } @@ -298,7 +309,7 @@ } } -.patchwork-call-prompt { +.patchwork-call-question { &[popover] { position: fixed; inset: auto; @@ -327,13 +338,13 @@ margin: 0; } - & .patchwork-call-prompt-who { + & .patchwork-call-question-who { display: flex; align-items: center; gap: 8px; } - & .patchwork-call-prompt-faces { + & .patchwork-call-question-faces { display: flex; & .patchwork-call-face + .patchwork-call-face { @@ -341,16 +352,16 @@ } } - & .patchwork-call-prompt-title { + & .patchwork-call-question-title { font: 600 13px/1.3 var(--call-heading-family); } - & .patchwork-call-prompt-ask { + & .patchwork-call-question-ask { margin-top: 6px; color: var(--call-muted); } - & .patchwork-call-prompt-buttons { + & .patchwork-call-question-buttons { display: flex; gap: 6px; margin-top: 10px; @@ -382,7 +393,7 @@ } } - & .patchwork-call-prompt-foot { + & .patchwork-call-question-foot { display: flex; align-items: baseline; justify-content: space-between; @@ -423,6 +434,18 @@ z-index: 50; pointer-events: none; + /* in the top layer (a popover), over a frame's own: none of a popover's + own look */ + &[popover] { + width: auto; + height: auto; + margin: 0; + padding: 0; + border: 0; + background: transparent; + overflow: visible; + } + & .patchwork-call-float { position: absolute; display: grid; @@ -510,12 +533,12 @@ background: linear-gradient( 135deg, transparent 50%, - rgb(255 255 255 / 0.9) 50% 60%, + var(--call-on-video-ink) 50% 60%, transparent 60% 70%, - rgb(255 255 255 / 0.9) 70% 80%, + var(--call-on-video-ink) 70% 80%, transparent 80% ); - filter: drop-shadow(0 0 1px rgb(0 0 0 / 0.6)); + filter: drop-shadow(0 0 1px var(--call-on-video-shadow)); opacity: 0; transition: opacity 120ms ease-out; } @@ -631,13 +654,24 @@ /* someone filling the panes: their picture, all of it, on dark, their name in the corner, and you in the other. filling the screen, you're bigger */ .patchwork-call-stage { - position: absolute; + position: fixed; z-index: 40; + box-sizing: border-box; + + &[popover] { + inset: auto; + margin: 0; + padding: 0; + border: 0; + max-width: none; + max-height: none; + } + display: grid; place-items: center; overflow: hidden; - background: #111; - color: white; + background: var(--call-stage-fill); + color: var(--call-stage-ink); transition: opacity 180ms cubic-bezier(0.23, 1, 0.32, 1); @starting-style { @@ -669,7 +703,7 @@ gap: 6px; padding: 3px 8px; border-radius: 4px; - background: rgb(0 0 0 / 0.55); + background: var(--call-on-video-scrim); font: 600 13px/1.3 var(--call-sans); } @@ -684,8 +718,8 @@ padding: 0; border: none; border-radius: 50%; - background: rgb(0 0 0 / 0.55); - color: white; + background: var(--call-on-video-scrim); + color: var(--call-stage-ink); cursor: pointer; opacity: 0; transition: opacity 160ms ease-out; @@ -702,7 +736,7 @@ bottom: 12px; width: clamp(96px, 18%, 220px); border-radius: 8px; - box-shadow: 0 4px 16px rgb(0 0 0 / 0.45); + box-shadow: 0 4px 16px var(--call-float-shadow); & .patchwork-call-face { border-radius: 8px; @@ -742,7 +776,7 @@ display: grid; width: 100%; height: 100%; - background: #111; + background: var(--call-stage-fill); & video { width: 100%; @@ -756,7 +790,7 @@ left: 12px; width: clamp(72px, 14%, 180px); border-radius: 8px; - box-shadow: 0 4px 16px rgb(0 0 0 / 0.45); + box-shadow: 0 4px 16px var(--call-float-shadow); & .patchwork-call-face { border-radius: 8px; @@ -818,7 +852,7 @@ /* what's dragged out: a copy of the face, under the pointer (its place and size are its own: ui.js's ghostOf) */ .patchwork-call-ghost { - box-shadow: 0 4px 14px rgb(0 0 0 / 0.3); + box-shadow: 0 4px 14px var(--call-float-shadow-soft); } /* one face filling the sidebar's box, you in its corner */ @@ -846,8 +880,8 @@ bottom: 8px; padding: 2px 6px; border-radius: 4px; - background: rgb(0 0 0 / 0.55); - color: white; + background: var(--call-on-video-scrim); + color: var(--call-stage-ink); font: 600 11px/1.3 var(--call-sans); } @@ -857,7 +891,7 @@ bottom: 8px; width: 32%; border-radius: 6px; - box-shadow: 0 2px 8px rgb(0 0 0 / 0.4); + box-shadow: 0 2px 8px var(--call-float-shadow-soft); } } diff --git a/call/src/ui/ui.js b/call/src/ui/ui.js index d871bd7f..93f53aaa 100644 --- a/call/src/ui/ui.js +++ b/call/src/ui/ui.js @@ -119,44 +119,45 @@ function Picture(props) { /** * the question: share your video, and call (or join the one that's on). - * shows when something asks (call.js's ask), by what asked, or else at the - * top of `props.near` + * shows when something's pressed to ask (call.js's ask), where it asked, or + * else near the top left. pressed, it's yours to answer: it has the focus */ -export function CallPrompt(props) { +export function CallQuestion() { let popover; let first; const asking = call.asking; - const others = createMemo(() => (asking() ? onCallIn(asking().room.url) : [])); + const others = createMemo(() => (asking() ? onCallIn(asking().url) : [])); const elsewhere = () => { const now = call.get(); - return now && asking() && now.room !== asking().room.url ? now : null; + return now && asking() && now.room !== asking().url ? now : null; }; createEffect(() => { const now = asking(); if (!popover) return; if (!now) { - if (popover.matches(":popover-open")) popover.hidePopover(); + popover.removeAttribute("data-open"); + if (popover.matches?.(":popover-open")) popover.hidePopover(); return; } - // (a getter: what it's near now) - const near = props.near?.getBoundingClientRect(); - const at = now.at ?? (near ? { x: near.left + 12, y: near.top + 12 } : { x: 16, y: 16 }); + const at = now.at ?? { x: 16, y: 16 }; popover.style.left = `${Math.max(8, Math.min(at.x, innerWidth - 268))}px`; popover.style.top = `${Math.max(8, Math.min(at.y, innerHeight - 180))}px`; - if (!popover.matches(":popover-open")) popover.showPopover(); - // asked for, it's yours to answer; arriving to one, it waits for you - if (now.focus) first?.focus({ preventScroll: true }); + popover.setAttribute("data-open", ""); + try { + if (!popover.matches(":popover-open")) popover.showPopover(); + } catch {} + first?.focus({ preventScroll: true }); }); function go(video) { const now = asking(); call.stopAsking(); - if (now) join(now, { video }); + if (now) join(now.url, { video }); } return html`