-
+
-
-
Attaching…
+
${termControlsHtml()}
@@ -336,14 +364,18 @@ export function createShell(
shelf?.open(root);
});
syncFilesBadge();
- latestButton = wireTermControls({
+ termControls = wireTermControls({
root,
sessionId: () => sessionId,
terminalView: () => terminalView,
- sessionSize: () => ({ cols: sessionCols, rows: sessionRows }),
onControlSize: (cols, rows) => callbacks.onControlSize(cols, rows),
+ onLocalSize: (cols, rows) => resizeLocalTerminal(cols, rows),
onBuilder: () => openBuilder(),
- }).latestButton;
+ });
+ latestButton = termControls.latestButton;
+ // In fullscreen the latest button follows the pane's own scroll (see
+ // latestHidden); the pane outlives terminal remounts on this route.
+ root.querySelector("#term-pane")?.addEventListener("scroll", syncLatest, { passive: true });
uploadView = shelf
? mountUploads({
root,
@@ -704,11 +736,9 @@ export function createShell(
}
callbacks.onTerminalInput(bytes);
},
- onFollowChange: ({ following }) => {
- if (latestButton) {
- latestButton.hidden = following;
- }
- },
+ onFollowChange: () => syncLatest(),
+ // A zoom changes how many cells fit: the size owner re-asserts.
+ onZoomChange: () => termControls?.schedule(),
},
);
terminalView.mount(pane, cols, rows);
@@ -723,10 +753,21 @@ export function createShell(
}
});
}
- // A remembered control-size toggle re-asserts on every (re)mount, so
- // a reconnected session keeps the size this device asked for.
- if (readControlSize(id)) {
- callbacks.onControlSize(cols, rows);
+ // A remembered control-size toggle re-asserts on every (re)mount —
+ // the size that fits this panel, never the snapshot's — so a
+ // reconnected session keeps the size this device asked for.
+ termControls?.reassert();
+ },
+ sessionResized(id: string, cols: number, rows: number): void {
+ if (route !== "session" || id !== sessionId) {
+ return;
+ }
+ // The controls decide: a follower resizes in place and refits, an
+ // owner that was overridden lets go first.
+ if (termControls) {
+ termControls.handleResized(cols, rows);
+ } else {
+ resizeLocalTerminal(cols, rows);
}
},
writeTerminal(data: Uint8Array): void {
@@ -775,6 +816,18 @@ export function createShell(
},
};
+ function syncLatest(): void {
+ if (!latestButton) {
+ return;
+ }
+ const pane = root.querySelector
("#term-pane");
+ latestButton.hidden = latestHidden(
+ terminalView?.isFollowing ?? true,
+ terminalView?.isAlternate ?? false,
+ pane?.scrollTop ?? 0,
+ );
+ }
+
// Remounting the terminal (a changed host size) drops the view only;
// the rest of the session route stays painted around it.
function teardownTerminal(): void {
diff --git a/apps/remote/web/src/entry-path.ts b/apps/remote/web/src/entry-path.ts
index 58126c9..9ddec27 100644
--- a/apps/remote/web/src/entry-path.ts
+++ b/apps/remote/web/src/entry-path.ts
@@ -53,3 +53,90 @@ export function redirectUnknownRoute(
history.replaceState(null, "", homeHref(location));
return true;
}
+
+/** Where a guest link waits out the GitHub sign-in round trip (this tab). */
+export const GUEST_RETURN_KEY = "canopy.remote.guestReturn";
+/** A remembered guest link older than this is ignored: 15 minutes. */
+export const GUEST_RETURN_TTL_MS = 15 * 60 * 1000;
+const GUEST_PATH = /^\/g\/[1-9][0-9]*\/[0-9a-f]{16}\/?$/;
+
+/** The sessionStorage slice the return trip needs. */
+export interface ReturnStorage {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+}
+
+function sessionStore(): ReturnStorage | null {
+ try {
+ return globalThis.sessionStorage ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Remember the guest link this tab signs in from: the OAuth callback
+ * lands on `/`, which would otherwise drop the guest on the pairing form.
+ * Only the pathname is kept; the invite code has its own tab-only slot.
+ */
+export function rememberGuestReturn(
+ pathname: string,
+ nowMs = Date.now(),
+ storage: ReturnStorage | null = sessionStore(),
+): void {
+ if (!GUEST_PATH.test(pathname)) {
+ return;
+ }
+ try {
+ storage?.setItem(GUEST_RETURN_KEY, JSON.stringify({ path: pathname, at: nowMs }));
+ } catch {
+ // Without storage the guest opens the link again after signing in.
+ }
+}
+
+/**
+ * Back from sign-in at `/`: rewrite the URL to the remembered guest link,
+ * in place, and forget it. True when it did. A stale, malformed or absent
+ * entry changes nothing (and a stale or malformed one is dropped).
+ */
+export function resumeGuestReturn(
+ location: { pathname: string },
+ history: { replaceState(data: unknown, unused: string, url?: string): void },
+ nowMs = Date.now(),
+ storage: ReturnStorage | null = sessionStore(),
+): boolean {
+ if (location.pathname !== "/") {
+ return false;
+ }
+ let raw: string | null;
+ try {
+ raw = storage?.getItem(GUEST_RETURN_KEY) ?? null;
+ if (raw !== null) {
+ storage?.removeItem(GUEST_RETURN_KEY);
+ }
+ } catch {
+ return false;
+ }
+ if (raw === null) {
+ return false;
+ }
+ let entry: unknown;
+ try {
+ entry = JSON.parse(raw);
+ } catch {
+ return false;
+ }
+ const { path, at } = (entry ?? {}) as { path?: unknown; at?: unknown };
+ if (
+ typeof path !== "string" ||
+ !GUEST_PATH.test(path) ||
+ typeof at !== "number" ||
+ nowMs - at < 0 ||
+ nowMs - at > GUEST_RETURN_TTL_MS
+ ) {
+ return false;
+ }
+ history.replaceState(null, "", path);
+ return true;
+}
diff --git a/apps/remote/web/src/fake-host.ts b/apps/remote/web/src/fake-host.ts
index d91f0ed..8140e7d 100644
--- a/apps/remote/web/src/fake-host.ts
+++ b/apps/remote/web/src/fake-host.ts
@@ -13,6 +13,7 @@ import { createFakeShelfDeleter } from "./fake-delete.js";
import { createFakeGraphs } from "./fake-graphs.js";
import { GraphStore } from "./graph-store.js";
import { createGuestSession } from "./guest.js";
+import { INVITE_REFUSED_MESSAGE } from "./guest-invite.js";
import { MetricsStore } from "./metrics-store.js";
import { buildCatalog, buildList, buildSpawn, newReqId } from "./protocol.js";
import type { PromptDraft, SessionItem, ShelfItem } from "./protocol.js";
@@ -120,6 +121,20 @@ function main(): void {
const sent: unknown[] = [];
const terminalOutput: Uint8Array[] = [];
let seq = 7;
+ // Each session's PTY size as the fake host holds it: 80×24 until a
+ // resize changes it, and every change is announced as `resized`, the
+ // way the real host tells every attached device.
+ const ptySizes = new Map();
+ const ptySize = (id: string): { cols: number; rows: number } =>
+ ptySizes.get(id) ?? { cols: 80, rows: 24 };
+ const setPtySize = (id: string, cols: number, rows: number): void => {
+ const current = ptySize(id);
+ if (current.cols === cols && current.rows === rows) {
+ return;
+ }
+ ptySizes.set(id, { cols, rows });
+ deliver({ t: "resized", id, cols, rows });
+ };
let nextSpawn = 0;
let promptError: { code: string; message: string } | null = null;
@@ -385,6 +400,8 @@ function main(): void {
mime?: string;
part?: number;
data?: string;
+ cols?: number;
+ rows?: number;
};
sent.push(frame);
if (frame.t === "list") {
@@ -421,7 +438,8 @@ function main(): void {
deliver({ t: "error", code: "forbidden", id: frame.id });
} else {
const full = b64(SMOKE_OUTPUT);
- deliver({ t: "snapshot", id: frame.id, cols: 80, rows: 24, seq, data: full.slice(0, 10) });
+ const { cols, rows } = ptySize(frame.id);
+ deliver({ t: "snapshot", id: frame.id, cols, rows, seq, data: full.slice(0, 10) });
deliver({ t: "snapshot_more", id: frame.id, data: full.slice(10), last: true });
const attached = frame.id;
if (pushedFor === null) {
@@ -432,6 +450,13 @@ function main(): void {
}, 2000);
}
}
+ } else if (
+ frame.t === "resize" &&
+ typeof frame.id === "string" &&
+ typeof frame.cols === "number" &&
+ typeof frame.rows === "number"
+ ) {
+ setPtySize(frame.id, frame.cols, frame.rows);
} else if (frame.t === "shelf_put_begin" && typeof frame.req === "string") {
if (uploadMode === "denied") {
deliver({ t: "error", req: frame.req, code: "forbidden", message: "upload not allowed" });
@@ -544,15 +569,35 @@ function main(): void {
// Guest mode: the /g/ session wiring against the fake host — no relay,
// no peer, the same guest views, store, controller and router that
- // production mounts, already connected.
+ // production mounts, already connected. `&invite=ask` starts on the
+ // invite form instead (a link without #invite=), and `&invite=refuse`
+ // also refuses the first code the way the relay's 403 bad_invite lands.
if (fakeParams.get("screen") === "guest") {
const guestOutput: Uint8Array[] = [];
+ const inviteMode = fakeParams.get("invite");
+ const invites: string[] = [];
+ let refuseNext = inviteMode === "refuse";
+ const admit = (): void => {
+ session.view.setConnectionState("connected");
+ session.view.showList();
+ send(buildList(newReqId()));
+ };
const session = createGuestSession(root, {
login: "guest-user",
send,
onRetry: () => {},
onSignOut: () => {},
onForgetDevice: () => {},
+ onInviteSubmit: (code) => {
+ invites.push(code);
+ if (refuseNext) {
+ refuseNext = false;
+ session.view.setConnectionState("failed: invite not accepted");
+ session.view.showInvite(INVITE_REFUSED_MESSAGE, code);
+ return;
+ }
+ admit();
+ },
onTerminalBytes: (bytes) => {
guestOutput.push(bytes);
},
@@ -561,9 +606,12 @@ function main(): void {
},
});
deliverInto = (raw: string) => session.push(raw);
- session.view.setConnectionState("connected");
- session.view.showList();
- send(buildList(newReqId()));
+ if (inviteMode === null) {
+ admit();
+ } else {
+ session.view.setConnectionState("connecting");
+ session.view.showInvite(null, "");
+ }
window.__fake = {
sent: () => [...sent],
clearSent: () => {
@@ -591,6 +639,7 @@ function main(): void {
deliver(metricsFrame(patch ?? {}));
},
guestOpen: (id: string) => session.open(id),
+ guestInvites: () => [...invites],
};
return;
}
@@ -768,6 +817,9 @@ function main(): void {
pushMetrics: (patch?: Record) => {
deliver(metricsFrame(patch ?? {}));
},
+ resizeFromElsewhere: (cols: number, rows: number, id = "smoke") => {
+ setPtySize(id, cols, rows);
+ },
};
}
@@ -793,6 +845,10 @@ declare global {
pushMetrics: (patch?: Record) => void;
/** Guest mode only: open one session by id, listed or not. */
guestOpen?: (id: string) => void;
+ /** Guest mode only: the canonical codes the invite form submitted. */
+ guestInvites?: () => string[];
+ /** Another device (or the host's TUI) resizes a session's PTY. */
+ resizeFromElsewhere?: (cols: number, rows: number, id?: string) => void;
};
}
}
diff --git a/apps/remote/web/src/guest-invite.ts b/apps/remote/web/src/guest-invite.ts
new file mode 100644
index 0000000..b526897
--- /dev/null
+++ b/apps/remote/web/src/guest-invite.ts
@@ -0,0 +1,128 @@
+// SPDX-License-Identifier: Apache-2.0
+// The guest's invite code: read from the link's fragment, canonicalised
+// the way the host does, kept for this tab only, and validated when typed.
+//
+// The host prints `/g//#invite=`. The
+// fragment never reaches the relay, its logs or a Referer; this page reads
+// it, strips it from the address bar and keeps the code in sessionStorage
+// alone, so a reload or a reconnect in the same tab still has it while no
+// other tab, and nothing after the tab closes, ever does. The relay hashes
+// exactly the string it receives and the host publishes the SHA-256 of
+// the canonical `CNR-XXXX-XXXX-XXXX-XXXX` form, so only that form is sent.
+
+import type { GuestLink } from "./guest.js";
+
+/** The minimal Storage slice the invite needs (sessionStorage in prod). */
+export interface InviteStorage {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+}
+
+/** Crockford base32, as the host's pairing and invite codes use it. */
+const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+/** Symbols in one code, without the `CNR` prefix and the dashes. */
+const CODE_SYMBOLS = 16;
+/** Where the tab keeps one link's code. */
+const STORAGE_PREFIX = "canopy.remote.invite:";
+
+/** Shown under the form when the typed text is not a code at all. */
+export const INVITE_FORMAT_ERROR =
+ "That is not an invite code: it reads CNR- and 16 letters or digits.";
+
+/** The relay refused the code before this tab ever joined with it. */
+export const INVITE_REFUSED_MESSAGE =
+ "The relay did not accept this invite code. It may be mistyped, expired or revoked, " +
+ "or already used: each code works once. Check it, or ask the host for a new one.";
+
+/** The relay refused the code after it let this tab in once: it is spent. */
+export const INVITE_SPENT_MESSAGE =
+ "Your invite code was used up when you joined: each code works once. " +
+ "Ask the host for a new one to reconnect.";
+
+/**
+ * Hand-typed input to the canonical `CNR-XXXX-XXXX-XXXX-XXXX` form, or
+ * null when it is not a code. Mirrors the host's normalize_code_input:
+ * dashes and whitespace ignored, ASCII case folded, Crockford confusables
+ * read as digits (`I`/`L` as 1, `O` as 0), the `CNR` prefix optional.
+ */
+export function canonicalInviteCode(input: string): string | null {
+ let core = input
+ .replace(/[-\s]/g, "")
+ .replace(/[a-z]/g, (c) => c.toUpperCase())
+ .replace(/[IL]/g, "1")
+ .replace(/O/g, "0");
+ if (core.length === 3 + CODE_SYMBOLS && core.startsWith("CNR")) {
+ core = core.slice(3);
+ }
+ if (core.length !== CODE_SYMBOLS || [...core].some((c) => !ALPHABET.includes(c))) {
+ return null;
+ }
+ return `CNR-${core.slice(0, 4)}-${core.slice(4, 8)}-${core.slice(8, 12)}-${core.slice(12)}`;
+}
+
+/** The form's verdict on typed text: an error message, or null when valid. */
+export function inviteFormError(input: string): string | null {
+ return canonicalInviteCode(input) === null ? INVITE_FORMAT_ERROR : null;
+}
+
+/**
+ * Take the `invite` parameter out of the URL fragment (`#invite=`):
+ * the raw value, or null when the fragment carries none. A fragment that
+ * carries one is stripped from the address bar in place (no reload, no
+ * history entry), so the code is not left on screen or in the history.
+ */
+export function takeInviteFragment(
+ location: { pathname: string; search: string; hash: string },
+ history: { replaceState(data: unknown, unused: string, url?: string): void },
+): string | null {
+ if (location.hash.length < 2) {
+ return null;
+ }
+ const code = new URLSearchParams(location.hash.slice(1)).get("invite");
+ if (code === null) {
+ return null;
+ }
+ history.replaceState(null, "", location.pathname + location.search);
+ return code;
+}
+
+function defaultStorage(): InviteStorage | null {
+ try {
+ return globalThis.sessionStorage ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function keyFor(link: GuestLink): string {
+ return `${STORAGE_PREFIX}${link.ownerUid}/${link.hostId}`;
+}
+
+/** This tab's code for one guest link, re-checked; null when none. */
+export function loadInvite(link: GuestLink, storage = defaultStorage()): string | null {
+ try {
+ const stored = storage?.getItem(keyFor(link)) ?? null;
+ return stored === null ? null : canonicalInviteCode(stored);
+ } catch {
+ return null;
+ }
+}
+
+/** Keep the canonical code for this tab; a storage that refuses is fine. */
+export function saveInvite(link: GuestLink, code: string, storage = defaultStorage()): void {
+ try {
+ storage?.setItem(keyFor(link), code);
+ } catch {
+ // Without storage the code lives in memory only: a reload asks again.
+ }
+}
+
+/** Drop this tab's code for one guest link. */
+export function forgetInvite(link: GuestLink, storage = defaultStorage()): void {
+ try {
+ storage?.removeItem(keyFor(link));
+ } catch {
+ // Nothing stored, or nothing to clear.
+ }
+}
diff --git a/apps/remote/web/src/guest-views.ts b/apps/remote/web/src/guest-views.ts
index 644d4d7..593c711 100644
--- a/apps/remote/web/src/guest-views.ts
+++ b/apps/remote/web/src/guest-views.ts
@@ -8,7 +8,9 @@
// bundle's chunk. No pairing, no picker, no hub-level control of any kind.
import { armSessionHint } from "./auth.js";
+import { canonicalInviteCode, inviteFormError } from "./guest-invite.js";
import { renderTopbar, wireAccount } from "./chrome.js";
+import { rememberGuestReturn } from "./entry-path.js";
import { syncHeaderFondo } from "./fondo.js";
import { REMOTE_FOOTER } from "./footer.js";
import { renderKeyBar, updateKeyBarLatches } from "./keys.js";
@@ -27,16 +29,25 @@ export interface GuestViewCallbacks {
onForgetDevice: () => void;
/** Terminal and key-bar bytes with the latches applied. */
onInput: (bytes: Uint8Array) => void;
+ /** A valid invite code was submitted, already in its canonical form. */
+ onInviteSubmit?: (code: string) => void;
}
export interface GuestView {
showSignIn: () => void;
showInvalidLink: () => void;
+ /**
+ * Ask for the invite code: `notice` says why (a refused or malformed
+ * code), `code` prefills the field so a retry is one tap.
+ */
+ showInvite: (notice: string | null, code: string) => void;
showConnecting: () => void;
showForbidden: (message: string) => void;
showList: (notice?: string | null) => void;
showSession: (id: string, name: string) => void;
ensureTerminal: (id: string, cols: number, rows: number) => void;
+ /** The host resized the open session's PTY: follow it in place. */
+ sessionResized: (id: string, cols: number, rows: number) => void;
writeTerminal: (data: Uint8Array) => void;
clearTerminal: () => void;
setSessionStatus: (status: string | null) => void;
@@ -49,7 +60,7 @@ export interface GuestViewDeps {
terminalFactory?: TerminalFactory;
}
-type GuestRoute = "signin" | "invalid" | "connecting" | "forbidden" | "list" | "session";
+type GuestRoute = "signin" | "invalid" | "invite" | "connecting" | "forbidden" | "list" | "session";
function esc(text: string): string {
return text
@@ -72,6 +83,8 @@ export function createGuestView(
let signOutError: string | null = null;
let accountOpen = false;
let forbiddenMessage = "";
+ let inviteNotice: string | null = null;
+ let inviteDraft = "";
let listNotice: string | null = null;
let sessionId = "";
let sessionName = "";
@@ -140,10 +153,74 @@ export function createGuestView(
// back must fetch /auth/me. A cancelled login self-heals — the next
// 401 clears the hint again.
armSessionHint();
+ // The OAuth callback lands on /: remember this guest link (never the
+ // code, which keeps its own tab-only slot) for main.ts to resume.
+ rememberGuestReturn(globalThis.location.pathname);
globalThis.location.href = "/auth/login";
});
}
+ // The invite form: one field, checked against the code format on
+ // submit. The hint and the error are tied to the field (aria-describedby,
+ // aria-invalid), so a screen reader hears why it was refused; a refused
+ // code from the relay arrives as the notice above the form.
+ function paintInvite(): void {
+ route = "invite";
+ document.title = remoteTitle("Invite code");
+ teardownSessionWidgets();
+ root.innerHTML = `
+ ${header()}
+
+ Canopy remote
+ Enter the invite code the host sent you to see the sessions shared with you.
+ ${
+ inviteNotice === null
+ ? ""
+ : `${esc(inviteNotice)}
`
+ }
+
+
+ ${REMOTE_FOOTER}`;
+ wireChrome();
+ const form = root.querySelector("#guest-invite-form");
+ const input = root.querySelector("#guest-invite-code");
+ const error = root.querySelector("#guest-invite-error");
+ // A header repaint (the login, the connection state) must not drop
+ // what was typed so far.
+ input?.addEventListener("input", () => {
+ inviteDraft = input.value;
+ });
+ form?.addEventListener("submit", (ev) => {
+ ev.preventDefault();
+ if (!input || !error) {
+ return;
+ }
+ inviteDraft = input.value;
+ const canonical = canonicalInviteCode(input.value);
+ if (canonical === null) {
+ error.textContent = inviteFormError(input.value);
+ error.hidden = false;
+ input.setAttribute("aria-invalid", "true");
+ input.focus();
+ return;
+ }
+ error.textContent = "";
+ error.hidden = true;
+ input.setAttribute("aria-invalid", "false");
+ callbacks.onInviteSubmit?.(canonical);
+ });
+ }
+
function paintConnecting(): void {
route = "connecting";
document.title = remoteTitle("Connecting");
@@ -253,6 +330,8 @@ export function createGuestView(
function repaint(): void {
if (route === "signin" || route === "invalid") {
paintSignIn(route === "invalid");
+ } else if (route === "invite") {
+ paintInvite();
} else if (route === "connecting") {
paintConnecting();
} else if (route === "forbidden") {
@@ -290,6 +369,11 @@ export function createGuestView(
return {
showSignIn: () => paintSignIn(false),
showInvalidLink: () => paintSignIn(true),
+ showInvite: (notice: string | null, code: string) => {
+ inviteNotice = notice;
+ inviteDraft = code;
+ paintInvite();
+ },
showConnecting: () => paintConnecting(),
showForbidden: (message: string) => {
forbiddenMessage = message;
@@ -304,6 +388,17 @@ export function createGuestView(
sessionName = name;
paintSession();
},
+ sessionResized: (id: string, cols: number, rows: number) => {
+ if (route !== "session" || id !== sessionId || !terminalView) {
+ return;
+ }
+ // A guest never owns the size: it follows the host's in place, and a
+ // snapshot at that size later does not remount.
+ if (terminalView.resize(cols, rows)) {
+ sessionCols = cols;
+ sessionRows = rows;
+ }
+ },
ensureTerminal: (id: string, cols: number, rows: number) => {
if (route !== "session" || id !== sessionId) {
return;
diff --git a/apps/remote/web/src/guest.ts b/apps/remote/web/src/guest.ts
index a627b56..85abaf2 100644
--- a/apps/remote/web/src/guest.ts
+++ b/apps/remote/web/src/guest.ts
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
-// The guest side of a /g// link: GitHub sign-in
-// through the existing app, a guest relay socket into the owner's room, a
-// TOFU WebRTC handshake with the named host, and the shared session list.
+// The guest side of a /g//#invite= link: GitHub
+// sign-in through the existing app, the host-issued invite code (from the
+// fragment, or typed into a form), a guest relay socket into the owner's
+// room opened with that code, a TOFU WebRTC handshake with the named host,
+// and the shared session list.
// The hub authorizes everything from the relayed GitHub identity: this
// side only renders what arrives and fails closed on forbidden.
@@ -21,6 +23,16 @@ import type { DeviceKey } from "./crypto.js";
import { getGuestHost, guestHostKey, saveGuestHost } from "./guest-store.js";
import type { GuestHostRecord } from "./guest-store.js";
import { guardGuestSend } from "./guest-guard.js";
+import {
+ INVITE_REFUSED_MESSAGE,
+ INVITE_SPENT_MESSAGE,
+ canonicalInviteCode,
+ forgetInvite,
+ loadInvite,
+ saveInvite,
+ takeInviteFragment,
+} from "./guest-invite.js";
+import type { InviteStorage } from "./guest-invite.js";
import { createGuestView } from "./guest-views.js";
import type { GuestView } from "./guest-views.js";
import type { TerminalFactory } from "./terminal.js";
@@ -58,9 +70,13 @@ export function parseGuestLink(pathname: string): GuestLink | null {
return { ownerUid, hostId: match[2] as string };
}
-/** The relay door for a guest of one owner. */
-export function guestRelayUrl(ownerUid: number): string {
- return `${defaultRelayUrl()}?guest_of=${ownerUid}`;
+/**
+ * The relay door for a guest of one owner, carrying the invite code. The
+ * relay hashes the decoded `invite` value as is, so `code` must already be
+ * canonical (see canonicalInviteCode).
+ */
+export function guestRelayUrl(ownerUid: number, code: string): string {
+ return `${defaultRelayUrl()}?guest_of=${ownerUid}&invite=${encodeURIComponent(code)}`;
}
export interface GuestPeerDeps {
@@ -438,6 +454,8 @@ export interface GuestSessionDeps {
onRetry: () => void;
onSignOut: () => void;
onForgetDevice: () => void;
+ /** A valid invite code was submitted from the invite form. */
+ onInviteSubmit?: (code: string) => void;
/** Injected in tests; production opens the real xterm terminal. */
terminalFactory?: TerminalFactory;
/** Test hook the fake host uses to read terminal output back. */
@@ -481,6 +499,7 @@ export function createGuestSession(root: HTMLElement, deps: GuestSessionDeps): G
onRetry: deps.onRetry,
onSignOut: deps.onSignOut,
onForgetDevice: deps.onForgetDevice,
+ onInviteSubmit: (code) => deps.onInviteSubmit?.(code),
onInput: (bytes) => {
controller.sendInputBytes(bytes);
},
@@ -515,6 +534,7 @@ export function createGuestSession(root: HTMLElement, deps: GuestSessionDeps): G
// bypasses this entirely and reaches onForbidden below.
const routerShell: RouterShell = {
ensureTerminal: (id, cols, rows) => view.ensureTerminal(id, cols, rows),
+ sessionResized: (id, cols, rows) => view.sessionResized(id, cols, rows),
sheetSpawned: () => {},
sheetComposed: () => {},
sheetOk: () => {},
@@ -552,21 +572,97 @@ export function createGuestSession(root: HTMLElement, deps: GuestSessionDeps): G
};
}
+/** What mountGuest reaches outside itself; tests replace any of it. */
+export interface GuestMountDeps {
+ location: { pathname: string; search: string; hash: string };
+ history: { replaceState(data: unknown, unused: string, url?: string): void };
+ /** This tab's storage for the invite code (sessionStorage). */
+ storage: InviteStorage | null;
+ fetchLogin: () => Promise;
+ signOut: () => Promise;
+ ensureDeviceKey: () => Promise;
+ clearDevice: () => Promise;
+ /** One relay socket for one door URL. */
+ createRelay: (url: string) => RelaySocket;
+ /** False when the browser knows it is offline. */
+ isOnline: () => boolean;
+ /** Extra GuestPeer deps (TURN, RTCPeerConnection, pins) for tests. */
+ peer?: Partial;
+ terminalFactory?: TerminalFactory;
+}
+
+function sessionStore(): InviteStorage | null {
+ try {
+ return globalThis.sessionStorage ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function defaultMountDeps(): GuestMountDeps {
+ return {
+ location: globalThis.location,
+ history: globalThis.history,
+ storage: sessionStore(),
+ fetchLogin,
+ signOut,
+ ensureDeviceKey,
+ clearDevice,
+ createRelay: (url) => new RelaySocket(url),
+ isOnline: () => globalThis.navigator?.onLine !== false,
+ };
+}
+
/** Mount the guest app behind a /g/ link; main.ts calls this and returns. */
-export async function mountGuest(root: HTMLElement): Promise {
+export async function mountGuest(root: HTMLElement, overrides: Partial = {}): Promise {
+ const deps: GuestMountDeps = { ...defaultMountDeps(), ...overrides };
let peer: GuestPeer | null = null;
let relay: RelaySocket | null = null;
let keepalive: RelayKeepalive | null = null;
let revoked = false;
+ // The code this tab joins with, and whether the host ever let this tab
+ // in with it: invites are single-use, so a later refusal means spent.
+ let invite: string | null = null;
+ let admitted = false;
+ let inviteNotice: string | null = null;
+ let inviteDraft = "";
+
+ const link = parseGuestLink(deps.location.pathname);
+ // The fragment goes first, before any await: the code leaves the address
+ // bar (and the history entry) at once, and waits in this tab's storage
+ // through a sign-in round trip. A malformed one prefills the form.
+ const fromFragment = link === null ? null : takeInviteFragment(deps.location, deps.history);
+ if (link !== null) {
+ if (fromFragment !== null) {
+ const canonical = canonicalInviteCode(fromFragment);
+ if (canonical === null) {
+ inviteNotice = "The invite code in this link is malformed. Check it, or ask the host for a new link.";
+ inviteDraft = fromFragment;
+ } else {
+ saveInvite(link, canonical, deps.storage);
+ }
+ }
+ invite = inviteNotice === null ? loadInvite(link, deps.storage) : null;
+ }
- const login = await fetchLogin();
- const link = parseGuestLink(globalThis.location.pathname);
+ const login = await deps.fetchLogin();
const session = createGuestSession(root, {
login,
send: (data) => peer?.send(data) ?? false,
onRetry: () => void connect(),
onSignOut: () => void doSignOut(false),
onForgetDevice: () => void doSignOut(true),
+ onInviteSubmit: (code) => {
+ if (link === null) {
+ return;
+ }
+ saveInvite(link, code, deps.storage);
+ invite = code;
+ admitted = false;
+ closeRelay();
+ void connect();
+ },
+ terminalFactory: deps.terminalFactory,
});
const view = session.view;
if (link === null) {
@@ -580,31 +676,58 @@ export async function mountGuest(root: HTMLElement): Promise {
// Plain consts: the hoisted connect() below cannot carry the null check.
const ownerUid = link.ownerUid;
const hostId = link.hostId;
- const device = await ensureDeviceKey();
- relay = new RelaySocket(guestRelayUrl(ownerUid));
- keepalive = new RelayKeepalive({ relay });
- keepalive.start();
- // A 4003 is the relay kicking a guest whose uid left the allowlist: tear
- // down and land on the terminal screen. The keepalive already stopped
- // itself on the same close, so this socket never reconnects.
- relay.onClose((ev) => {
- if (ev.code !== 4003) {
- return;
- }
- revoked = true;
- session.detach();
- peer?.close();
- peer = null;
+ const device = await deps.ensureDeviceKey();
+
+ // One socket per code: the door URL carries it, so a new code is a new
+ // socket. A 4003 is the relay kicking a guest whose uid left the
+ // allowlist: tear down and land on the terminal screen. The keepalive
+ // already stopped itself on the same close, so it never reconnects.
+ function openRelay(code: string): RelaySocket {
+ const socket = deps.createRelay(guestRelayUrl(ownerUid, code));
+ relay = socket;
+ keepalive = new RelayKeepalive({ relay: socket });
+ keepalive.start();
+ socket.onClose((ev) => {
+ if (ev.code !== 4003 || relay !== socket) {
+ return;
+ }
+ revoked = true;
+ session.detach();
+ peer?.close();
+ peer = null;
+ closeRelay();
+ view.showForbidden("access revoked");
+ });
+ return socket;
+ }
+
+ function closeRelay(): void {
keepalive?.stop();
- relay?.close();
- view.showForbidden("access revoked");
- });
+ keepalive = null;
+ const socket = relay;
+ relay = null;
+ socket?.close();
+ }
+
+ // The relay refuses a bad door with an HTTP status (403 bad_invite) the
+ // browser never shows to the page: a socket that closes before it ever
+ // opened, while online, is the door saying no. Drop the code from this
+ // tab and ask again, prefilled so a retry is one tap.
+ function inviteRefused(code: string): void {
+ closeRelay();
+ forgetInvite(link as GuestLink, deps.storage);
+ invite = null;
+ view.setConnectionState("failed: invite not accepted");
+ view.showInvite(admitted ? INVITE_SPENT_MESSAGE : INVITE_REFUSED_MESSAGE, code);
+ }
async function connect(): Promise {
- const socket = relay;
- if (socket === null) {
+ const code = invite;
+ if (code === null) {
+ view.showInvite(inviteNotice, inviteDraft);
return;
}
+ const socket = relay ?? openRelay(code);
view.showConnecting();
view.setConnectionState("connecting");
peer?.close();
@@ -614,15 +737,22 @@ export async function mountGuest(root: HTMLElement): Promise {
await socket.connect();
} catch {
// A kick racing the open already painted the terminal screen above.
- if (!revoked) {
+ if (revoked || relay !== socket) {
+ return;
+ }
+ if (deps.isOnline()) {
+ inviteRefused(code);
+ } else {
view.setConnectionState("failed: Could not reach the relay");
}
return;
}
- const next = new GuestPeer({ relay: socket, ownerUid, hostId, device });
+ const next = new GuestPeer({ ...deps.peer, relay: socket, ownerUid, hostId, device });
peer = next;
next.onEvent((ev) => {
if (ev.type === "open") {
+ // The host accepted the offer: the code is spent from here on.
+ admitted = true;
view.setConnectionState("connected");
view.showList();
next.send(buildList(newReqId()));
@@ -658,7 +788,7 @@ export async function mountGuest(root: HTMLElement): Promise {
async function doSignOut(forget: boolean): Promise {
view.setSignOutError(null);
try {
- await signOut();
+ await deps.signOut();
} catch (err) {
view.setSignOutError(err instanceof Error ? err.message : "Could not sign out");
return;
@@ -668,11 +798,10 @@ export async function mountGuest(root: HTMLElement): Promise {
session.detach();
peer?.close();
peer = null;
- keepalive?.stop();
- relay?.close();
+ closeRelay();
if (forget) {
try {
- await clearDevice();
+ await deps.clearDevice();
} catch {
// The session is already dead; a stale key row is repaired on
// the next pair, never a reason to hold the sign-in screen.
diff --git a/apps/remote/web/src/icons-dom.ts b/apps/remote/web/src/icons-dom.ts
index eef18e7..298a85e 100644
--- a/apps/remote/web/src/icons-dom.ts
+++ b/apps/remote/web/src/icons-dom.ts
@@ -36,6 +36,7 @@ import {
deleteLeft,
folder,
folderOpen,
+ keyboard,
loader,
logOut,
maximize,
@@ -71,6 +72,7 @@ const REMOTE_ICONS = {
deleteLeft,
folder,
folderOpen,
+ keyboard,
loader,
logOut,
maximize,
diff --git a/apps/remote/web/src/main.ts b/apps/remote/web/src/main.ts
index e781004..01b4f20 100644
--- a/apps/remote/web/src/main.ts
+++ b/apps/remote/web/src/main.ts
@@ -11,7 +11,7 @@ import { CatalogStore } from "./catalog.js";
import { createChannelHandler } from "./channel.js";
import { generateDeviceKey } from "./crypto.js";
import type { DeviceKey } from "./crypto.js";
-import { redirectUnknownRoute } from "./entry-path.js";
+import { redirectUnknownRoute, resumeGuestReturn } from "./entry-path.js";
import { GraphStore } from "./graph-store.js";
import { MetricsStore } from "./metrics-store.js";
import { buildCatalog, buildCompose, buildList, buildSpawn, newReqId } from "./protocol.js";
@@ -50,6 +50,9 @@ async function main(): Promise {
// guest checks below then read "/" and never match, and the app boots
// on its own route — no reload, no round-trip, query and hash kept.
redirectUnknownRoute(globalThis.location, globalThis.history);
+ // Back from a guest's GitHub sign-in (the callback lands on /): resume
+ // the guest link this tab left from, so the check below mounts it.
+ resumeGuestReturn(globalThis.location, globalThis.history);
// The graph editor rides its own lazy chunk: no relay, no pairing, no
// login — the drafts API it posts to is public and CORS-open.
if (globalThis.location?.pathname.startsWith("/editor") === true) {
diff --git a/apps/remote/web/src/protocol.ts b/apps/remote/web/src/protocol.ts
index 2c2c7e7..ad1cee9 100644
--- a/apps/remote/web/src/protocol.ts
+++ b/apps/remote/web/src/protocol.ts
@@ -178,6 +178,30 @@ export function parseExited(frame: unknown): { id: string; code: number } | null
return { id: frame.id, code: frame.code };
}
+export interface ResizedFrame {
+ id: string;
+ cols: number;
+ rows: number;
+}
+
+// The host's size_field bounds: integers 1..=65535.
+function isPtySize(value: unknown): value is number {
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
+}
+
+// `resized{id,cols,rows}`: a session's PTY size changed on the host —
+// another device asserted its size, or the local TUI resized it. Older
+// hosts never send it; anything malformed is dropped like an unknown frame.
+export function parseResized(frame: unknown): ResizedFrame | null {
+ if (frameType(frame) !== "resized" || !isRecord(frame)) {
+ return null;
+ }
+ if (typeof frame.id !== "string" || frame.id === "" || !isPtySize(frame.cols) || !isPtySize(frame.rows)) {
+ return null;
+ }
+ return { id: frame.id, cols: frame.cols, rows: frame.rows };
+}
+
function b64Bytes(data: string): Uint8Array | null {
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) {
return null;
diff --git a/apps/remote/web/src/router.ts b/apps/remote/web/src/router.ts
index ebf4fcb..3c5b2ac 100644
--- a/apps/remote/web/src/router.ts
+++ b/apps/remote/web/src/router.ts
@@ -24,6 +24,7 @@ import {
parseHello,
parseOk,
parseOut,
+ parseResized,
parseSessionAdded,
parseSessionRemoved,
parseSessions,
@@ -48,6 +49,8 @@ export interface FrameRouter {
*/
export interface RouterShell {
ensureTerminal: (id: string, cols: number, rows: number) => void;
+ /** The open session's PTY size changed on the host (a `resized` frame). */
+ sessionResized?: (id: string, cols: number, rows: number) => void;
sheetSpawned: (req: string, id: string) => void;
sheetComposed: (req: string, text: string) => void;
sheetOk: (req: string) => void;
@@ -104,6 +107,14 @@ export function createFrameRouter(deps: FrameRouterDeps): FrameRouter {
controller.handleOut(out);
return;
}
+ // Only the attached session's size matters; any other is dropped.
+ const resized = parseResized(frame);
+ if (resized) {
+ if (controller.sessionId === resized.id) {
+ shell.sessionResized?.(resized.id, resized.cols, resized.rows);
+ }
+ return;
+ }
// Graph watching: the list, the watch reply (summary + drawing) and
// the live-state pushes. Each parser is additive, so a host that
// sends none of them changes nothing.
diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css
index fb8d482..b21acf4 100644
--- a/apps/remote/web/src/styles.css
+++ b/apps/remote/web/src/styles.css
@@ -464,6 +464,34 @@ label > textarea {
white-space: nowrap;
}
+/* The guest's invite form sits in the sign-in card: the field and its
+ * hint read left to right like the pairing form; the relay's refusal is
+ * the error tint above it, with the same "Error:" label. */
+.invite-form {
+ text-align: left;
+}
+
+.invite-hint {
+ margin: -0.25rem 0 0.75rem;
+ font-size: 0.85rem;
+ color: var(--muted);
+}
+
+.invite-notice {
+ color: var(--ink);
+ background: var(--tint-error);
+ border: 1px solid var(--tint-line);
+ border-radius: var(--radius);
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ overflow-wrap: anywhere;
+}
+
+.invite-notice::before {
+ content: "Error: ";
+ color: var(--err-label);
+}
+
#pair-error:empty {
display: none;
}
@@ -650,6 +678,14 @@ label > textarea {
touch-action: pan-x;
}
+/* While this device controls the size, the rows are measured from this
+ * box, so its height must not follow the terminal (that would feed the
+ * rows back into themselves): a fixed share of the viewport, which the
+ * fullscreen and orientation changes move on their own. */
+.term-pane[data-size-owner] .term-fit {
+ height: 40dvh;
+}
+
/* The visual box: natural size × the scale refit wrote, clipping the
* transformed host so the terminal scales exactly once. At zoom 1 it
* matches the fit exactly; zoomed wider, the pane scrolls it. */
@@ -661,56 +697,153 @@ label > textarea {
transform-origin: top left;
}
+/* The row wraps rather than clips as a last resort: the latest button
+ * floats over the terminal (below), so with fullscreen and both attach
+ * buttons up the row holds seven 44 px targets and stays one row tall on
+ * a phone. */
.term-controls {
display: flex;
+ flex-wrap: wrap;
gap: var(--gap);
}
/* Every control beside the terminal keeps the 44x44 touch floor: the base
- * button height is 2.75rem and this widens the short icon buttons. */
+ * button height is 2.75rem and this widens the short icon buttons. A
+ * labelled button grows to its label (icon and word side by side, never
+ * stacked) instead of squeezing it past its border. */
.term-controls button {
min-width: 2.75rem;
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ padding-inline: 0.6rem;
+ white-space: nowrap;
+}
+
+/* The terminal card plus the latest button floating over its
+ * bottom-right corner: the stage is the positioning box, so the button
+ * stays put while the pane scrolls beneath it. It only shows once the view
+ * left the bottom (or, in fullscreen, the frame's top), when the cursor
+ * line is off screen; a 44 px round target in the key look, inset by the
+ * pane's padding, and slightly see-through until pointed at or focused. */
+.term-stage {
+ position: relative;
+ min-height: 0;
}
#term-latest {
- margin-left: auto;
+ position: absolute;
+ right: 0.5rem;
+ bottom: 0.5rem;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.75rem;
+ height: 2.75rem;
+ min-width: 2.75rem;
+ padding: 0;
+ border-radius: 50%;
+ opacity: 0.85;
+}
+
+#term-latest:hover,
+#term-latest:focus-visible {
+ opacity: 1;
}
#term-latest[hidden] {
display: none;
}
-/* The per-session size control: the toggle and its one-line explainer sit
- * side by side and wrap on narrow phones. */
+/* The per-session size control, laid out like a settings row: the switch
+ * on the left, its words on the first line beside it and the explainer
+ * (or the takeover notice) on the second. The label spans both lines, so
+ * the whole 44 px row is the switch's target (audit E4, like .yolo-row)
+ * without the row growing taller than the two lines of text. */
.term-size {
- display: flex;
- align-items: baseline;
- gap: var(--gap);
- flex-wrap: wrap;
+ display: grid;
+ grid-template-columns: 2.75rem minmax(0, 1fr);
+ column-gap: 0.5rem;
}
.term-size-toggle {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
+ grid-area: 1 / 1 / 3 / 3;
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
font-size: 0.85rem;
margin: 0;
+ min-height: 44px;
+ min-width: 44px;
+ cursor: pointer;
}
-/* The toggle's checkbox keeps its own box: without this the global
- * label > input rule stretches it into a full-width 44 px control and
- * the size row doubles in height. */
+/* The checkbox drawn as a switch: a track in the key-bar tint with a
+ * thumb that slides right and fills teal when on, the latched-key look.
+ * It keeps its own box: without the width and min-height here the global
+ * label > input rule stretches it into a full-width control. */
.term-size-toggle input {
+ appearance: none;
+ -webkit-appearance: none;
+ position: relative;
+ flex: none;
+ align-self: center;
display: inline-block;
- width: auto;
+ width: 2.75rem;
+ height: 1.5rem;
min-height: 0;
margin: 0;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--key-bg);
+ cursor: pointer;
+}
+
+.term-size-toggle input::before {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: calc(1.5rem - 6px);
+ height: calc(1.5rem - 6px);
+ border-radius: 50%;
+ background: var(--muted);
+ transition: transform 120ms ease-out;
+}
+
+.term-size-toggle input:checked {
+ background: var(--accent);
+ border-color: var(--accent);
+}
+
+.term-size-toggle input:checked::before {
+ background: var(--accent-ink);
+ transform: translateX(1.25rem);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .term-size-toggle input::before {
+ transition: none;
+ }
}
.term-size-note {
+ grid-area: 2 / 2 / 3 / 3;
margin: 0;
font-size: 0.8rem;
color: var(--muted);
+ /* Taps on the note still reach the label beneath it. */
+ pointer-events: none;
+}
+
+/* Another device took the size over: the note says so in the ink colour
+ * (the words carry it, the colour only lifts it off the muted line). */
+.term-size-note.is-notice {
+ color: var(--ink);
}
/* Ten keys, all visible: two rows of five equal-width touch targets.
@@ -776,7 +909,11 @@ label > textarea {
gap: 0.15rem;
}
- #term-builder span {
+ /* The labelled controls go icon-only on a phone: the aria-label (and
+ * the title tooltip) names them, and seven 44 px targets stay inside
+ * 360 px without a word overflowing its button. */
+ #term-builder span,
+ #term-keys span {
display: none;
}
diff --git a/apps/remote/web/src/term-controls.ts b/apps/remote/web/src/term-controls.ts
index 8beb552..79e813f 100644
--- a/apps/remote/web/src/term-controls.ts
+++ b/apps/remote/web/src/term-controls.ts
@@ -2,11 +2,21 @@
// The session view's terminal controls: zoom, the key-bar switch, the
// Prompt Builder button, the fullscreen button, the latest button, and
// the per-session control-size toggle with its explainer. Rendered as
-// HTML beside the terminal pane and wired to the open TerminalView plus
+// HTML beside the terminal pane (the latest button floats over the
+// pane's bottom-right corner) and wired to the open TerminalView plus
// the shell's live session state.
+//
+// The control-size toggle makes this device the size owner: it computes
+// the cols × rows that fit its own panel (TerminalView.ownedSize), resizes
+// the local terminal to them and asserts them to the host, then re-asserts
+// (debounced) whenever the panel changes — a resize, an orientation
+// change, fullscreen, the zoom — for as long as the toggle stays on.
+// The host's `resized` frame lands in handleResized: a follower resizes
+// to it, and an owner whose size was replaced by another device's lets go
+// (no resize ping-pong) and says so in the size row's note.
import { icon } from "./icons-dom.js";
-import type { TerminalView } from "./terminal.js";
+import type { TermSize, TerminalView } from "./terminal.js";
import {
readControlSize,
readKeyBarVisible,
@@ -46,33 +56,76 @@ export function termControlsHtml(): string {
aria-pressed="${readKeyBarVisible() ? "true" : "false"}"
aria-label="Show terminal keys"
title="Show or hide the terminal keys"
- >Keys
+ >${icon("keyboard")}Keys
${fullscreen}
-
`;
}
+/**
+ * The latest button, floating over the terminal card's bottom-right
+ * corner rather than in the toolbar row, where an eighth 44 px target
+ * wrapped onto a row of its own on a phone and cost the terminal a row.
+ * Icon-only at every width: the aria-label and the title name it. The
+ * shell renders it inside the `.term-stage` beside `#term-pane`.
+ */
+export function termLatestHtml(): string {
+ return `
+
`;
+}
+
+/**
+ * Whether the latest button stays hidden. On the shell's buffer it shows
+ * while the view no longer follows the bottom. A fullscreen app has no
+ * tail to follow (follow stays off) and the button floats over the frame,
+ * often over the app's own input line, so there it shows only while the
+ * pane is scrolled away from the frame's top, which a tap brings back.
+ */
+export function latestHidden(following: boolean, alternate: boolean, paneScrollTop: number): boolean {
+ return alternate ? paneScrollTop <= 0 : following;
+}
+
+const SIZE_NOTE = "Only one client should control the size.";
+/** The size row's note once another device replaced this one's size. */
+export const SIZE_TAKEN_NOTE = "Size taken over by another device.";
+
// The size row lives below the keybar, not beside the controls: the ten
// keys must all stay inside a 360×640 viewport, and any row above them
-// pushes the second key row out of view.
+// pushes the second key row out of view. The checkbox is drawn as a
+// switch (role="switch" announces on/off) and the whole label is its
+// 44 px target; Space toggles it and the focus ring is the global one.
export function termSizeHtml(sessionId: string | null): string {
return `
-
Only one client should control the size.
+ >
This device controls the size
+
${SIZE_NOTE}
`;
}
+/** The slice of the open TerminalView the controls drive. */
+export type TermControlsView = Pick<
+ TerminalView,
+ "zoomIn" | "zoomOut" | "scrollToLatest" | "isAlternate" | "fitted" | "ready" | "ownedSize" | "setSizeOwner"
+>;
+
export interface TermControlsDeps {
root: HTMLElement;
sessionId: () => string | null;
- terminalView: () => TerminalView | null;
- sessionSize: () => { cols: number; rows: number };
+ terminalView: () => TermControlsView | null;
+ /** Send resize{id,cols,rows}: this device asserts its size to the host. */
onControlSize: (cols: number, rows: number) => void;
+ /** Resize the local terminal to cols × rows (the shell tracks the size). */
+ onLocalSize: (cols: number, rows: number) => void;
/** Open the Prompt Builder sheet; the controls row owns its button. */
onBuilder: () => void;
}
@@ -80,6 +133,35 @@ export interface TermControlsDeps {
export interface TermControls {
/** The latest button, for the shell to reveal as follow changes. */
latestButton: HTMLButtonElement | null;
+ /**
+ * A terminal (re)mounted: a remembered toggle asserts the size that fits
+ * this panel again, even when it did not change, so a reconnected or
+ * resized session keeps the size this device asked for.
+ */
+ reassert: () => void;
+ /** The panel layout changed (the zoom): re-assert, debounced, while on. */
+ schedule: () => void;
+ /**
+ * The host reports the session's PTY size. A follower resizes to it; an
+ * owner whose own size it is not (another device took over) turns its
+ * toggle off, remembers that, and follows.
+ */
+ handleResized: (cols: number, rows: number) => void;
+ /** Stop watching the panel; the session view is going away. */
+ dispose: () => void;
+}
+
+/** Panel changes settle this long before the size is asserted again. */
+export const SIZE_DEBOUNCE_MS = 150;
+// A measurement that is not ready yet (the terminal still opening) retries
+// on the debounce this many times before waiting for the next change.
+const SIZE_MEASURE_RETRIES = 5;
+// The sizes this device sent that the host may still echo back: a slow
+// echo of an older one must not read as another device taking over.
+const MAX_ASSERTED = 8;
+
+function sameSize(a: TermSize | null, b: TermSize): boolean {
+ return a !== null && a.cols === b.cols && a.rows === b.rows;
}
export function wireTermControls(deps: TermControlsDeps): TermControls {
@@ -135,18 +217,150 @@ export function wireTermControls(deps: TermControlsDeps): TermControls {
}
const latestButton = root.querySelector
("#term-latest");
latestButton?.addEventListener("click", () => {
- const view = deps.terminalView();
- view?.scrollToLatest();
- // In fullscreen follow stays off, so the button stays visible; anywhere
- // else the tap means "follow now" and hides it.
- if (latestButton && !view?.isAlternate) {
+ deps.terminalView()?.scrollToLatest();
+ // The tap means "follow now", or in fullscreen "back to the frame's
+ // top": either way there is nothing left to jump to.
+ if (latestButton) {
latestButton.hidden = true;
}
});
- // Per-session, default off: while on, the device asserts its PTY size
- // to the host. Turning it on sends the open terminal's real cols and
- // rows at once; turning it off just stops asserting.
+ // Per-session, default off: while on, the device asserts the size that
+ // fits its own panel. Turning it on sends that size at once (and resizes
+ // the local terminal to it); turning it off stops asserting and the
+ // terminal follows the host's size again.
const sizeToggle = root.querySelector("#term-control-size");
+ const sizeNote = root.querySelector(".term-size-note");
+ let owning = false;
+ let lastSent: TermSize | null = null;
+ let asserted: TermSize[] = [];
+ let timer: ReturnType | null = null;
+ let retries = 0;
+ let generation = 0;
+ let unwatch: (() => void) | null = null;
+
+ const clearTimer = (): void => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ const schedule = (): void => {
+ if (!owning) {
+ return;
+ }
+ clearTimer();
+ timer = setTimeout(() => {
+ timer = null;
+ assertSize(false);
+ }, SIZE_DEBOUNCE_MS);
+ };
+
+ // Measure and send: an unchanged size is not sent again unless forced
+ // (a fresh mount, where the host may hold any size).
+ function assertSize(force: boolean): void {
+ if (!owning || deps.sessionId() === null) {
+ return;
+ }
+ const view = deps.terminalView();
+ if (!view) {
+ return;
+ }
+ const size = view.ownedSize();
+ if (size === null) {
+ if (retries < SIZE_MEASURE_RETRIES) {
+ retries += 1;
+ schedule();
+ }
+ return;
+ }
+ retries = 0;
+ if (!force && sameSize(lastSent, size)) {
+ return;
+ }
+ lastSent = size;
+ asserted.push(size);
+ if (asserted.length > MAX_ASSERTED) {
+ asserted.shift();
+ }
+ deps.onLocalSize(size.cols, size.rows);
+ deps.onControlSize(size.cols, size.rows);
+ }
+
+ // The panel changes that move the size: its own box (a window resize, a
+ // keyboard, the key bar), an orientation change and fullscreen. The zoom
+ // reaches schedule() through the shell.
+ const watch = (): void => {
+ if (unwatch !== null) {
+ return;
+ }
+ const pane = root.querySelector("#term-pane");
+ const observer =
+ pane !== null && typeof ResizeObserver !== "undefined"
+ ? new ResizeObserver(() => schedule())
+ : null;
+ if (pane !== null) {
+ observer?.observe(pane);
+ }
+ globalThis.addEventListener("resize", schedule);
+ globalThis.addEventListener("orientationchange", schedule);
+ document.addEventListener("fullscreenchange", schedule);
+ unwatch = () => {
+ observer?.disconnect();
+ globalThis.removeEventListener("resize", schedule);
+ globalThis.removeEventListener("orientationchange", schedule);
+ document.removeEventListener("fullscreenchange", schedule);
+ };
+ };
+
+ const stopWatching = (): void => {
+ unwatch?.();
+ unwatch = null;
+ clearTimer();
+ };
+
+ const setNote = (text: string): void => {
+ if (sizeNote) {
+ sizeNote.textContent = text;
+ sizeNote.classList.toggle("is-notice", text !== SIZE_NOTE);
+ }
+ };
+
+ const startOwning = (): void => {
+ owning = true;
+ lastSent = null;
+ asserted = [];
+ setNote(SIZE_NOTE);
+ retries = 0;
+ generation += 1;
+ watch();
+ const view = deps.terminalView();
+ if (!view) {
+ // No terminal yet: its mount re-asserts.
+ return;
+ }
+ view.setSizeOwner(true);
+ if (view.fitted) {
+ assertSize(true);
+ return;
+ }
+ const pending = generation;
+ void view.ready().then(() => {
+ if (pending === generation && owning && deps.terminalView() === view) {
+ assertSize(true);
+ }
+ });
+ };
+
+ const stopOwning = (): void => {
+ owning = false;
+ lastSent = null;
+ asserted = [];
+ generation += 1;
+ stopWatching();
+ deps.terminalView()?.setSizeOwner(false);
+ };
+
sizeToggle?.addEventListener("change", () => {
const id = deps.sessionId();
if (id === null) {
@@ -154,10 +368,54 @@ export function wireTermControls(deps: TermControlsDeps): TermControls {
}
const next = sizeToggle.checked;
writeControlSize(id, next);
- const { cols, rows } = deps.sessionSize();
- if (next && cols > 0 && rows > 0) {
- deps.onControlSize(cols, rows);
+ if (next) {
+ startOwning();
+ } else {
+ stopOwning();
}
});
- return { latestButton };
+
+ return {
+ latestButton,
+ reassert: () => {
+ const id = deps.sessionId();
+ if (id === null || !readControlSize(id)) {
+ return;
+ }
+ if (sizeToggle) {
+ sizeToggle.checked = true;
+ }
+ startOwning();
+ },
+ schedule,
+ handleResized: (cols, rows) => {
+ const reported = { cols, rows };
+ if (owning && asserted.length > 0) {
+ const index = asserted.findIndex((size) => sameSize(size, reported));
+ if (index >= 0) {
+ // The host confirms one of ours: drop the older ones.
+ asserted = asserted.slice(index);
+ return;
+ }
+ // Another device (or the host's own TUI) set a size this device
+ // never asked for: let it have the size rather than fight back.
+ const id = deps.sessionId();
+ if (id !== null) {
+ writeControlSize(id, false);
+ }
+ if (sizeToggle) {
+ sizeToggle.checked = false;
+ }
+ stopOwning();
+ setNote(SIZE_TAKEN_NOTE);
+ }
+ // A follower (or an owner yet to assert) takes the host's size.
+ deps.onLocalSize(cols, rows);
+ },
+ dispose: () => {
+ owning = false;
+ generation += 1;
+ stopWatching();
+ },
+ };
}
diff --git a/apps/remote/web/src/terminal.ts b/apps/remote/web/src/terminal.ts
index 11d148e..07a4fea 100644
--- a/apps/remote/web/src/terminal.ts
+++ b/apps/remote/web/src/terminal.ts
@@ -21,11 +21,17 @@
// and on buffer change. Every refit anchors the pane at scrollLeft 0, so
// the line start stays visible and a stale scroll never clips the left
// edge.
+//
+// While this device controls the size (the control-size toggle), the
+// direction flips: the grid is computed from the panel (ownedSize) and the
+// local terminal is resized to it, so it renders at its base scale (the
+// face at 14 CSS px) times the zoom instead of being fitted to the width.
import "@xterm/xterm/css/xterm.css";
import { Terminal } from "@xterm/xterm";
import {
TERMINAL_FONT_FAMILY,
+ TERMINAL_FONT_SIZE,
terminalFontSizeForDpr,
terminalFontGate,
} from "./terminal-font.js";
@@ -50,6 +56,13 @@ export interface TerminalHandle {
onScroll: (callback: () => void) => () => void;
/** Fires once the terminal is open (and the WebGL attempt settled). */
onReady: (callback: () => void) => () => void;
+ /** Resize the local terminal to cols × rows. Absent means a fixed size. */
+ resize?: (cols: number, rows: number) => void;
+ /**
+ * The scale that shows the opened face at TERMINAL_FONT_SIZE CSS px: the
+ * DPR-1 double-size face renders at 0.5. Absent means 1.
+ */
+ baseScale?: number;
dispose: () => void;
}
@@ -67,6 +80,43 @@ export function clampZoom(zoom: number): number {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
}
+export interface TermSize {
+ cols: number;
+ rows: number;
+}
+
+// The bounds of a size this device asserts: below them a session is
+// unusable, above them the panel measurement is surely wrong.
+export const OWNED_MIN_COLS = 20;
+export const OWNED_MAX_COLS = 500;
+export const OWNED_MIN_ROWS = 5;
+export const OWNED_MAX_ROWS = 200;
+
+function clampInt(value: number, low: number, high: number): number {
+ return Math.min(high, Math.max(low, value));
+}
+
+// The cols × rows that fit a panel of panelWidth × panelHeight CSS px with
+// cells of cellWidth × cellHeight CSS px (as they render on screen), within
+// the owned bounds. Null when either box is not measured yet.
+export function computeOwnedSize(
+ panelWidth: number,
+ panelHeight: number,
+ cellWidth: number,
+ cellHeight: number,
+): TermSize | null {
+ if (!(panelWidth > 0 && panelHeight > 0 && cellWidth > 0 && cellHeight > 0)) {
+ return null;
+ }
+ // The epsilon keeps an exact fit (336 / 8.4) from flooring one short.
+ const cols = Math.floor(panelWidth / cellWidth + 1e-6);
+ const rows = Math.floor(panelHeight / cellHeight + 1e-6);
+ return {
+ cols: clampInt(cols, OWNED_MIN_COLS, OWNED_MAX_COLS),
+ rows: clampInt(rows, OWNED_MIN_ROWS, OWNED_MAX_ROWS),
+ };
+}
+
// 11 CSS px of glyph at the terminal's natural row height.
export function computeMinScale(rowHeight: number): number {
if (!(rowHeight > 0)) {
@@ -219,11 +269,12 @@ export function createXtermHandle(
// (see terminal-font.ts). The size is fixed at open — a later browser
// zoom lands as layout and the fit scale absorbs it, no re-open.
const dpr = typeof devicePixelRatio === "number" ? devicePixelRatio : undefined;
+ const fontSize = terminalFontSizeForDpr(dpr);
const terminal = new Terminal({
cols,
rows,
scrollback: 2000,
- fontSize: terminalFontSizeForDpr(dpr),
+ fontSize,
fontFamily: TERMINAL_FONT_FAMILY,
fontWeight: 400,
fontWeightBold: 700,
@@ -265,6 +316,8 @@ export function createXtermHandle(
callback();
return () => {};
},
+ resize: (nextCols, nextRows) => terminal.resize(nextCols, nextRows),
+ baseScale: TERMINAL_FONT_SIZE / fontSize,
dispose: () => terminal.dispose(),
};
}
@@ -272,6 +325,8 @@ export function createXtermHandle(
export interface TerminalViewCallbacks {
onUserInput: (text: string) => void;
onFollowChange?: (state: FollowState) => void;
+ /** Fires after the zoom changed (buttons or pinch). */
+ onZoomChange?: () => void;
}
export class TerminalView {
@@ -283,7 +338,9 @@ export class TerminalView {
private pane: HTMLElement | null = null;
private handle: TerminalHandle | null = null;
private zoom = 1;
+ private cols = 0;
private rows = 0;
+ private sizeOwner = false;
private following = true;
private alternate = false;
private stopObserving: (() => void) | null = null;
@@ -325,6 +382,7 @@ export class TerminalView {
this.scaler = scaler;
this.host = host;
this.pane = parent;
+ this.cols = cols;
this.rows = rows;
// Tapping anywhere in the pane — the terminal or its padding — focuses
// xterm's hidden textarea so the phone keyboard opens. The listener
@@ -382,10 +440,10 @@ export class TerminalView {
this.settleReady();
return;
}
- this.openTerminal(cols, rows);
+ this.openTerminal();
});
} else {
- this.openTerminal(cols, rows);
+ this.openTerminal();
this.readyPromise = Promise.resolve();
}
}
@@ -432,6 +490,72 @@ export class TerminalView {
setZoom(zoom: number): void {
this.zoom = clampZoom(zoom);
this.refit();
+ this.callbacks.onZoomChange?.();
+ }
+
+ /** True while this device asserts the PTY size (the control-size toggle). */
+ get isSizeOwner(): boolean {
+ return this.sizeOwner;
+ }
+
+ // While this device owns the size the terminal is sized to the panel, so
+ // it renders at its base scale times the zoom instead of being fitted;
+ // the pane carries data-size-owner so the stylesheet gives the fit box a
+ // height that never follows the content (rows are measured from it).
+ setSizeOwner(owner: boolean): void {
+ if (this.sizeOwner === owner) {
+ return;
+ }
+ this.sizeOwner = owner;
+ if (this.pane) {
+ if (owner) {
+ this.pane.dataset.sizeOwner = "true";
+ } else {
+ delete this.pane.dataset.sizeOwner;
+ }
+ }
+ this.refit();
+ }
+
+ // The cols × rows that fit this device's panel at the current font size
+ // and zoom: the cell is measured off the open terminal (screen / grid)
+ // and shown at the owner scale. Null until the terminal is open and laid
+ // out.
+ ownedSize(): TermSize | null {
+ const fit = this.fit;
+ const natural = this.naturalSize();
+ if (!this.handle || !fit || !natural || this.cols <= 0 || this.rows <= 0) {
+ return null;
+ }
+ const scale = this.ownerScale();
+ return computeOwnedSize(
+ fit.clientWidth,
+ fit.clientHeight,
+ (natural.width / this.cols) * scale,
+ (natural.height / this.rows) * scale,
+ );
+ }
+
+ // Resizes the local terminal (the host's size changed, or this device
+ // asserted its own) and refits. A terminal still behind the font gate
+ // opens at the new size. False when the size is invalid or unchanged.
+ resize(cols: number, rows: number): boolean {
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
+ return false;
+ }
+ if (cols === this.cols && rows === this.rows) {
+ return false;
+ }
+ this.cols = cols;
+ this.rows = rows;
+ this.handle?.resize?.(cols, rows);
+ this.refit();
+ return true;
+ }
+
+ /** The local terminal's current grid, 0 × 0 before mount. */
+ get size(): TermSize {
+ return { cols: this.cols, rows: this.rows };
}
zoomIn(): void {
@@ -460,21 +584,12 @@ export class TerminalView {
const fit = this.fit;
const scaler = this.scaler;
const host = this.host;
- if (!fit || !scaler || !host) {
- return;
- }
- const content = host.firstElementChild ?? host;
- const screen = host.querySelector(".xterm-screen");
- // The scale measures the real .xterm-screen, never the .xterm wrapper:
- // the wrapper can be narrower than the screen it holds, and a scale
- // from the wrapper overflows the panel on the right.
- const naturalWidth =
- screen && screen.scrollWidth > 0 ? screen.scrollWidth : content.scrollWidth;
- const naturalHeight =
- screen && screen.offsetHeight > 0 ? screen.offsetHeight : content.scrollHeight;
- if (naturalWidth <= 0 || naturalHeight <= 0) {
+ const natural = this.naturalSize();
+ if (!fit || !scaler || !host || !natural) {
return;
}
+ const naturalWidth = natural.width;
+ const naturalHeight = natural.height;
// Both renderers size .xterm-screen in layout px (a CSS transform does
// not affect offsetHeight), so this is the unscaled row height.
const rowHeight = this.rows > 0 ? naturalHeight / this.rows : FALLBACK_ROW_HEIGHT;
@@ -483,14 +598,18 @@ export class TerminalView {
// height, capped at 60vh by the stylesheet). Before any layout the
// box has no height yet, so alternate falls back to the width fit.
const fitAll = this.alternate && fit.clientHeight > 0;
- const scale = computeFitScale(
- fit.clientWidth,
- fit.clientHeight,
- naturalWidth,
- naturalHeight,
- this.zoom,
- fitAll,
- );
+ // A size owner's terminal already fits the panel by its grid: it shows
+ // at the base scale times the zoom, never stretched to the width.
+ const scale = this.sizeOwner
+ ? this.ownerScale()
+ : computeFitScale(
+ fit.clientWidth,
+ fit.clientHeight,
+ naturalWidth,
+ naturalHeight,
+ this.zoom,
+ fitAll,
+ );
// One scale, applied once: the host is natural-size and carries the
// transform; the scaler is the visual box (natural × scale) that
// clips it, so the terminal never renders at scale².
@@ -510,6 +629,30 @@ export class TerminalView {
}
}
+ // The terminal's unscaled pixel size. The scale measures the real
+ // .xterm-screen, never the .xterm wrapper: the wrapper can be narrower
+ // than the screen it holds, and a scale from the wrapper overflows the
+ // panel on the right.
+ private naturalSize(): { width: number; height: number } | null {
+ const host = this.host;
+ if (!host) {
+ return null;
+ }
+ const content = host.firstElementChild ?? host;
+ const screen = host.querySelector(".xterm-screen");
+ const width = screen && screen.scrollWidth > 0 ? screen.scrollWidth : content.scrollWidth;
+ const height = screen && screen.offsetHeight > 0 ? screen.offsetHeight : content.scrollHeight;
+ if (width <= 0 || height <= 0) {
+ return null;
+ }
+ return { width, height };
+ }
+
+ private ownerScale(): number {
+ const base = this.handle?.baseScale;
+ return (base !== undefined && base > 0 ? base : 1) * this.zoom;
+ }
+
dispose(): void {
this.generation += 1;
this.settleReady();
@@ -520,6 +663,10 @@ export class TerminalView {
this.paneClick.parent.removeEventListener("click", this.paneClick.handler);
this.paneClick = null;
}
+ if (this.pane) {
+ delete this.pane.dataset.sizeOwner;
+ }
+ this.sizeOwner = false;
this.handle?.dispose();
this.handle = null;
this.fit?.remove();
@@ -527,6 +674,7 @@ export class TerminalView {
this.scaler = null;
this.host = null;
this.pane = null;
+ this.cols = 0;
this.rows = 0;
this.pinchStart = null;
this.touchGesture = null;
@@ -535,13 +683,14 @@ export class TerminalView {
this.pendingWrites.length = 0;
}
- private openTerminal(cols: number, rows: number): void {
+ private openTerminal(): void {
const host = this.host;
if (!host) {
this.settleReady();
return;
}
- const handle = this.factory(host, cols, rows);
+ // The grid as it stands now: a resize behind the font gate counts.
+ const handle = this.factory(host, this.cols, this.rows);
this.handle = handle;
handle.onUserInput((text) => this.callbacks.onUserInput(text));
handle.onScroll(() => this.refreshFollow());
diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts
index b94af84..04cbadc 100644
--- a/apps/remote/web/test/app.test.ts
+++ b/apps/remote/web/test/app.test.ts
@@ -20,6 +20,7 @@ class FakeHandle implements TerminalHandle {
alternate = false;
cols: number;
rows: number;
+ resizes: { cols: number; rows: number }[] = [];
private inputListeners: ((text: string) => void)[] = [];
private scrollListeners: (() => void)[] = [];
private bufferListeners: (() => void)[] = [];
@@ -29,6 +30,12 @@ class FakeHandle implements TerminalHandle {
this.rows = rows;
}
+ resize = (cols: number, rows: number): void => {
+ this.cols = cols;
+ this.rows = rows;
+ this.resizes.push({ cols, rows });
+ };
+
write = (data: Uint8Array): void => {
this.written.push(data);
};
@@ -304,8 +311,13 @@ describe("createShell", () => {
expect(query("#term-zoom-in svg")).not.toBeNull();
expect(query("#term-builder svg")).not.toBeNull();
expect(query("#term-builder").textContent).toContain("Builder");
- // The Keys switch stays a word: it names the bar it shows.
+ // The Keys switch keeps its word beside a keyboard icon (the word
+ // steps aside on a phone; the aria-label names it there).
expect(query("#term-keys").textContent).toBe("Keys");
+ expect(query("#term-keys svg")).not.toBeNull();
+ expect(query("#term-keys").getAttribute("aria-label")).toBe("Show terminal keys");
+ expect(query("#term-latest").getAttribute("aria-label")).toBe("Scroll to the latest output");
+ expect(query("#term-latest").getAttribute("title")).toBe("Scroll to the latest output");
});
it("ends every view in the same UniverLab footer, after the main", () => {
@@ -568,6 +580,10 @@ describe("createShell", () => {
const toggle = query("#term-control-size");
expect(toggle.checked).toBe(false);
expect(toggle.closest("label")?.textContent).toContain("This device controls the size");
+ // A switch: the label wraps it (the whole label is the target) and
+ // it announces on/off.
+ expect(toggle.getAttribute("role")).toBe("switch");
+ expect(toggle.closest("label")?.classList.contains("term-size-toggle")).toBe(true);
expect(query(".term-size-note").textContent).toContain("Only one client should control the size");
expect(calls.controlSizes).toEqual([]);
// Mounting while off asserts nothing.
@@ -575,38 +591,138 @@ describe("createShell", () => {
expect(calls.controlSizes).toEqual([]);
});
- it("persists control-size per session and asserts the real size", () => {
- shell.showSession("s1", "build");
- shell.ensureTerminal("s1", 80, 24);
+ // happy-dom has no layout: a 400 × 320 px panel and a grid whose
+ // natural size follows the newest handle at 8 × 16 px cells, so the
+ // size that fits the panel is 50 × 20 whatever the host's size is.
+ const layOutPanel = (): void => {
+ const handle = handles[handles.length - 1];
+ const fit = root.querySelector(".term-fit");
+ const host = root.querySelector(".term-host");
+ if (!handle || !fit || !host) {
+ throw new Error("no terminal to lay out");
+ }
+ Object.defineProperty(fit, "clientWidth", { value: 400, configurable: true });
+ Object.defineProperty(fit, "clientHeight", { value: 320, configurable: true });
+ Object.defineProperty(host, "scrollWidth", { get: () => handle.cols * 8, configurable: true });
+ Object.defineProperty(host, "scrollHeight", { get: () => handle.rows * 16, configurable: true });
+ };
+
+ const flipControlSize = (checked: boolean): void => {
const toggle = query("#term-control-size");
- // Turning it on remembers the session and asserts the open size once.
- toggle.checked = true;
+ toggle.checked = checked;
toggle.dispatchEvent(new Event("change", { bubbles: true }));
+ };
+
+ it("asserts the size that fits this panel, never the host's own", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ layOutPanel();
+ // Turning it on remembers the session, resizes the local terminal to
+ // the panel and asserts that size once: re-sending the host's 80 × 24
+ // would change nothing.
+ flipControlSize(true);
expect(readControlSize("s1")).toBe(true);
expect(readControlSize("s2")).toBe(false);
- expect(calls.controlSizes).toEqual([{ cols: 80, rows: 24 }]);
- // A remount (a changed host size) re-asserts while on.
+ expect(calls.controlSizes).toEqual([{ cols: 50, rows: 20 }]);
+ expect(handles).toHaveLength(1);
+ expect(handles[0]?.resizes).toEqual([{ cols: 50, rows: 20 }]);
+ // The owner renders at scale 1, not fitted to the width.
+ expect(query(".term-pane").getAttribute("data-size-owner")).toBe("true");
+ expect(query(".term-fit").dataset.scale).toBe("1");
+ // The host's snapshot at the asserted size keeps the terminal as is.
+ shell.ensureTerminal("s1", 50, 20);
+ expect(handles).toHaveLength(1);
+ expect(calls.controlSizes).toHaveLength(1);
+ });
+
+ it("re-asserts the fitted size when a remount brings the host's size back", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ layOutPanel();
+ flipControlSize(true);
+ // A remount (a changed host size): the fresh terminal is laid out and
+ // the size that fits the panel goes out again.
shell.ensureTerminal("s1", 100, 40);
+ expect(handles).toHaveLength(2);
+ layOutPanel();
+ vi.advanceTimersByTime(1000);
expect(calls.controlSizes).toEqual([
- { cols: 80, rows: 24 },
- { cols: 100, rows: 40 },
+ { cols: 50, rows: 20 },
+ { cols: 50, rows: 20 },
]);
- // Same size: no remount, no duplicate assert.
- shell.ensureTerminal("s1", 100, 40);
- expect(calls.controlSizes).toHaveLength(2);
- // Turning it off remembers and stops asserting.
- query("#term-control-size").checked = false;
- query("#term-control-size").dispatchEvent(new Event("change", { bubbles: true }));
+ expect(handles[1]?.resizes).toEqual([{ cols: 50, rows: 20 }]);
+ });
+
+ it("re-asserts on zoom, debounced, and stops when turned off", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ layOutPanel();
+ flipControlSize(true);
+ // Zooming in grows the cell (8 × 16 → 10 × 20): fewer fit.
+ query("#term-zoom-in").click();
+ expect(calls.controlSizes).toHaveLength(1);
+ vi.advanceTimersByTime(150);
+ expect(calls.controlSizes).toEqual([
+ { cols: 50, rows: 20 },
+ { cols: 40, rows: 16 },
+ ]);
+ // Turning it off remembers, stops asserting and fits the width again.
+ flipControlSize(false);
expect(readControlSize("s1")).toBe(false);
+ expect(query(".term-pane").hasAttribute("data-size-owner")).toBe(false);
+ query("#term-zoom-out").click();
+ vi.advanceTimersByTime(1000);
expect(calls.controlSizes).toHaveLength(2);
});
+ it("follows the host's resized frame in place while not the size owner", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ handles[0]?.write(new TextEncoder().encode("kept"));
+ shell.sessionResized("s1", 120, 40);
+ // Resized in place: the same terminal, its content kept.
+ expect(handles).toHaveLength(1);
+ expect(handles[0]?.resizes).toEqual([{ cols: 120, rows: 40 }]);
+ expect(handles[0]?.written).toHaveLength(1);
+ // A snapshot at the new size does not remount.
+ shell.ensureTerminal("s1", 120, 40);
+ expect(handles).toHaveLength(1);
+ // Another session's frame changes nothing.
+ shell.sessionResized("s2", 90, 20);
+ expect(handles[0]?.resizes).toHaveLength(1);
+ expect(calls.controlSizes).toEqual([]);
+ });
+
+ it("turns the toggle off when another device takes the size over", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ layOutPanel();
+ flipControlSize(true);
+ expect(calls.controlSizes).toEqual([{ cols: 50, rows: 20 }]);
+ // The host echoes this device's own size: nothing changes.
+ shell.sessionResized("s1", 50, 20);
+ expect(query("#term-control-size").checked).toBe(true);
+ // Another device asserts 120 × 40: let it, follow it, say so.
+ shell.sessionResized("s1", 120, 40);
+ expect(query("#term-control-size").checked).toBe(false);
+ expect(readControlSize("s1")).toBe(false);
+ expect(query(".term-size-note").textContent).toBe("Size taken over by another device.");
+ expect(query(".term-pane").hasAttribute("data-size-owner")).toBe(false);
+ expect(handles[0]?.resizes.at(-1)).toEqual({ cols: 120, rows: 40 });
+ vi.advanceTimersByTime(1000);
+ expect(calls.controlSizes).toHaveLength(1);
+ });
+
it("re-asserts a remembered control-size toggle on mount", () => {
writeControlSize("s1", true);
shell.showSession("s1", "build");
expect(query("#term-control-size").checked).toBe(true);
shell.ensureTerminal("s1", 80, 24);
- expect(calls.controlSizes).toEqual([{ cols: 80, rows: 24 }]);
+ // Nothing measurable yet in happy-dom: the size goes out once laid out.
+ layOutPanel();
+ vi.advanceTimersByTime(150);
+ expect(calls.controlSizes).toEqual([{ cols: 50, rows: 20 }]);
+ expect(handles[0]?.resizes).toEqual([{ cols: 50, rows: 20 }]);
});
it("keeps the terminal across state changes", () => {
@@ -649,15 +765,35 @@ describe("createShell", () => {
expect(latest.hidden).toBe(true);
});
- it("keeps the latest button visible when tapped in fullscreen", () => {
+ it("floats the latest button over the terminal card, not in the toolbar", () => {
+ shell.showSession("s1", "build");
+ const latest = query("#term-latest");
+ expect(latest.closest(".term-controls")).toBeNull();
+ expect(latest.parentElement?.classList.contains("term-stage")).toBe(true);
+ expect(latest.parentElement?.querySelector(":scope > #term-pane")).not.toBeNull();
+ // Icon-only everywhere: the aria-label names it.
+ expect(latest.textContent?.trim()).toBe("");
+ expect(latest.querySelector("svg")).not.toBeNull();
+ });
+
+ it("in fullscreen shows latest only while the pane is scrolled off the frame top", () => {
shell.showSession("s1", "build");
shell.ensureTerminal("s1", 80, 24);
const latest = query("#term-latest");
+ const pane = query("#term-pane");
expect(latest.hidden).toBe(true);
+ // A fullscreen app paints from the frame's top: nothing to jump to,
+ // and the floating button would sit over the app's input line.
handles[0]?.emitBufferChange(true);
+ expect(latest.hidden).toBe(true);
+ pane.scrollTop = 120;
+ pane.dispatchEvent(new Event("scroll"));
expect(latest.hidden).toBe(false);
- // Follow stays off in fullscreen, so the tap must not hide the button.
+ // The tap brings the frame's top back and the button goes.
latest.click();
+ expect(latest.hidden).toBe(true);
+ pane.scrollTop = 120;
+ pane.dispatchEvent(new Event("scroll"));
expect(latest.hidden).toBe(false);
// Back to the shell: follow resumes and the button hides again.
handles[0]?.emitBufferChange(false);
diff --git a/apps/remote/web/test/bundle-size.test.ts b/apps/remote/web/test/bundle-size.test.ts
index 4bc3806..0978dc4 100644
--- a/apps/remote/web/test/bundle-size.test.ts
+++ b/apps/remote/web/test/bundle-size.test.ts
@@ -16,7 +16,9 @@
* The guest app rides its own lazy chunk behind `/g/`, partitioned out by
* its IndexedDB name ("canopy-remote-guests") and weighed against its own
* 6 KB budget; it shares the main stylesheet, so CSS needs no partition.
- * The main bundle must not grow for the guest either.
+ * The main bundle must not grow for the guest either, save the one step
+ * that must run before the chunk loads: resuming a guest link after the
+ * GitHub sign-in lands on `/` (GUEST_RETURN_BUDGET).
*
* The build goes to a temporary directory, never over apps/remote/web/dist.
*/
@@ -115,14 +117,54 @@ const UPLOAD_BUDGET = 4_096;
* made any implementation of the spec impossible.
*/
const SHELF_DELETE_BUDGET = 1_024;
+/**
+ * Allowance for the plate's arc bounds (cubicExtent, and the arrowhead end
+ * measured before the shift so the fail arcs stay inside the viewBox):
+ * measured +237 B gzip over the pre-change main (167 968 → 168 205), JS
+ * only. Added in the fix commit like SHELF_DELETE_BUDGET: the 102-byte
+ * headroom could not hold any bounds that read the drawn curve, and the
+ * shared plate code rides the main bundle.
+ */
+const ARC_BOUNDS_BUDGET = 512;
/**
* Allowance for merging the two parallel branches of the first release: each
* measured its budgets over the same base, so both spent the same headroom.
* Merged, main is 410 B gzip over the sum of those budgets, with no new code.
*/
const MERGE_BUDGET = 512;
+/**
+ * Allowance for the control-size fix (the panel-fitted size, the owner
+ * scale, the in-place local resize and the debounced re-assert while the
+ * toggle is on): measured +819 B gzip over the pre-change main. Added in
+ * the fix commit like SHELF_DELETE_BUDGET: the 102-byte headroom made any
+ * implementation of the fix impossible.
+ */
+const CONTROL_SIZE_BUDGET = 1_024;
+/**
+ * Allowance for the resized frame (its parser and route, the follower's
+ * in-place resize, the owner's takeover check and notice): measured
+ * +325 B gzip over the pre-change main. Added in the fix commit like
+ * CONTROL_SIZE_BUDGET: the 307-byte headroom made any implementation of
+ * the fix impossible.
+ */
+const RESIZED_BUDGET = 512;
+/**
+ * Allowance for the guest's sign-in return (main.ts resumes the guest link
+ * a tab signed in from, since the OAuth callback lands on `/`): measured
+ * +226 B gzip over the pre-change main. Added in the fix commit like RESIZED_BUDGET: it
+ * must run before the guest chunk loads, so it cannot ride that chunk.
+ */
+const GUEST_RETURN_BUDGET = 512;
/** The budget the lazy guest chunk is allowed, gzip bytes of JS. */
const GUEST_BUDGET = 6_144;
+/**
+ * Allowance for the guest invite (the #invite= fragment, the tab-only
+ * code, canonicalisation, the invite form and the refused-door path):
+ * measured +1 390 B gzip over the pre-change guest chunk. Added in the fix
+ * commit like RESIZED_BUDGET: the guest chunk sat 941 bytes under its
+ * budget, and the relay's door cannot be opened without the code.
+ */
+const GUEST_INVITE_BUDGET = 1_536;
/** The budget the lazy editor chunk is allowed, gzip bytes of JS. */
const EDITOR_BUDGET = 30_000;
/** The budget the editor's own stylesheet is allowed, gzip bytes of CSS. */
@@ -228,9 +270,13 @@ describe("the remote's bundle", () => {
BAND_BUDGET +
UPLOAD_BUDGET +
SHELF_DELETE_BUDGET +
- MERGE_BUDGET,
+ ARC_BOUNDS_BUDGET +
+ MERGE_BUDGET +
+ CONTROL_SIZE_BUDGET +
+ RESIZED_BUDGET +
+ GUEST_RETURN_BUDGET,
);
- expect(guestJs, JSON.stringify(report)).toBeLessThanOrEqual(GUEST_BUDGET);
+ expect(guestJs, JSON.stringify(report)).toBeLessThanOrEqual(GUEST_BUDGET + GUEST_INVITE_BUDGET);
expect(editorJs, JSON.stringify(report)).toBeLessThanOrEqual(EDITOR_BUDGET);
expect(editorCss, JSON.stringify(report)).toBeLessThanOrEqual(EDITOR_CSS_BUDGET);
// The app still ships the icons inline: no sprite chunk, no font request.
diff --git a/apps/remote/web/test/control-size.spec.ts b/apps/remote/web/test/control-size.spec.ts
new file mode 100644
index 0000000..f9a1497
--- /dev/null
+++ b/apps/remote/web/test/control-size.spec.ts
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: Apache-2.0
+// "This device controls the size" against the fake host: turning it on
+// asserts the cols × rows that fit this phone's panel (never the host's
+// own 80 × 24) and renders them unfitted; the host's resized frame then
+// either moves a follower's terminal in place or, for an owner whose size
+// another device replaced, turns the toggle off without fighting back.
+import { expect, test } from "@playwright/test";
+
+interface TermState {
+ cols: number;
+ rows: number;
+ owner: boolean;
+}
+
+async function termState(page: import("@playwright/test").Page): Promise {
+ return page.evaluate(() => {
+ const host = document.querySelector(".term-host") as
+ | (HTMLElement & { __xterm?: { cols: number; rows: number } })
+ | null;
+ const term = host?.__xterm;
+ if (!term) {
+ return null;
+ }
+ return {
+ cols: term.cols,
+ rows: term.rows,
+ owner: document.querySelector(".term-pane")?.hasAttribute("data-size-owner") ?? false,
+ };
+ });
+}
+
+async function resizesSent(page: import("@playwright/test").Page): Promise<{ cols: number; rows: number }[]> {
+ return page.evaluate(() =>
+ window.__fake
+ .sent()
+ .filter((frame) => (frame as { t?: string }).t === "resize")
+ .map((frame) => {
+ const { cols, rows } = frame as { cols: number; rows: number };
+ return { cols, rows };
+ }),
+ );
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/fake.html");
+ await page.locator('[data-session="smoke"]').click();
+ await expect.poll(() => page.evaluate(() => window.__fake.terminalText())).toContain("hello from the host");
+ await expect(page.locator(".term-fit")).toHaveAttribute("data-row-height", /\d/);
+});
+
+test("asserts the size that fits the phone's panel and renders it unfitted", async ({ page }) => {
+ expect(await resizesSent(page)).toEqual([]);
+ await page.locator(".term-size-toggle").click();
+ await expect.poll(() => resizesSent(page).then((sent) => sent.length)).toBe(1);
+ const [asserted] = await resizesSent(page);
+ // A 390 px phone holds far fewer than the host's 80 columns.
+ expect(asserted!.cols).toBeGreaterThanOrEqual(20);
+ expect(asserted!.cols).toBeLessThan(80);
+ expect(asserted!.rows).toBeGreaterThanOrEqual(5);
+ // The local terminal took that grid and owns it, and it fits the pane.
+ await expect.poll(() => termState(page)).toEqual({ ...asserted!, owner: true });
+ const fits = await page.evaluate(() => {
+ const pane = document.querySelector(".term-pane") as HTMLElement;
+ const doc = document.scrollingElement ?? document.documentElement;
+ return {
+ paneOverflow: pane.scrollWidth - pane.clientWidth,
+ pageOverflow: doc.scrollWidth - doc.clientWidth,
+ };
+ });
+ expect(fits.paneOverflow).toBeLessThanOrEqual(1);
+ expect(fits.pageOverflow).toBeLessThanOrEqual(1);
+ // Zooming in fits fewer cells: re-asserted once the change settles.
+ await page.locator("#term-zoom-in").click();
+ await expect.poll(() => resizesSent(page).then((sent) => sent.length)).toBe(2);
+ const zoomed = (await resizesSent(page))[1]!;
+ expect(zoomed.cols).toBeLessThan(asserted!.cols);
+ // The remembered toggle re-asserts after a reload.
+ await page.reload();
+ await page.locator('[data-session="smoke"]').click();
+ await expect(page.locator("#term-control-size")).toBeChecked();
+ await expect.poll(() => resizesSent(page).then((sent) => sent.length)).toBeGreaterThan(0);
+ await expect.poll(() => termState(page).then((state) => state?.owner)).toBe(true);
+});
+
+test("follows a host resize in place while not the size owner", async ({ page }) => {
+ await page.evaluate(() => window.__fake.resizeFromElsewhere?.(100, 30));
+ await expect.poll(() => termState(page)).toEqual({ cols: 100, rows: 30, owner: false });
+ // Fitted to the width again at the new grid: no sideways scroll.
+ const paneOverflow = await page.evaluate(() => {
+ const pane = document.querySelector(".term-pane") as HTMLElement;
+ return pane.scrollWidth - pane.clientWidth;
+ });
+ expect(paneOverflow).toBeLessThanOrEqual(1);
+ expect(await resizesSent(page)).toEqual([]);
+});
+
+test("lets another device take the size over without fighting", async ({ page }) => {
+ await page.locator(".term-size-toggle").click();
+ await expect.poll(() => resizesSent(page).then((sent) => sent.length)).toBe(1);
+ await page.evaluate(() => window.__fake.resizeFromElsewhere?.(120, 40));
+ await expect(page.locator("#term-control-size")).not.toBeChecked();
+ await expect(page.locator(".term-size-note")).toHaveText("Size taken over by another device.");
+ await expect.poll(() => termState(page)).toEqual({ cols: 120, rows: 40, owner: false });
+ // Nothing goes back: the other device keeps the size.
+ await page.setViewportSize({ width: 360, height: 640 });
+ await page.waitForTimeout(400);
+ expect(await resizesSent(page)).toHaveLength(1);
+ // And the choice is remembered off.
+ expect(await page.evaluate(() => localStorage.getItem("canopy.remote.term.controlSize.smoke"))).toBe("0");
+});
diff --git a/apps/remote/web/test/guest-invite.test.ts b/apps/remote/web/test/guest-invite.test.ts
new file mode 100644
index 0000000..7ef00ae
--- /dev/null
+++ b/apps/remote/web/test/guest-invite.test.ts
@@ -0,0 +1,182 @@
+// SPDX-License-Identifier: Apache-2.0
+import { createHash } from "node:crypto";
+import { describe, expect, it } from "vitest";
+import {
+ GUEST_RETURN_KEY,
+ GUEST_RETURN_TTL_MS,
+ rememberGuestReturn,
+ resumeGuestReturn,
+} from "../src/entry-path.js";
+import {
+ INVITE_FORMAT_ERROR,
+ canonicalInviteCode,
+ forgetInvite,
+ inviteFormError,
+ loadInvite,
+ saveInvite,
+ takeInviteFragment,
+} from "../src/guest-invite.js";
+import { FakeStorage } from "./fakes.js";
+
+const LINK = { ownerUid: 1001, hostId: "76805a63a6980c18" };
+const CODE = "CNR-7K3Q-HM9P-2XAB-CDEF";
+const NOW = 1793100000_000;
+
+function fakeHistory(location: { pathname: string; search: string; hash: string }): {
+ calls: (string | undefined)[];
+ replaceState: (data: unknown, unused: string, url?: string) => void;
+} {
+ const calls: (string | undefined)[] = [];
+ return {
+ calls,
+ replaceState: (_data, _unused, url) => {
+ calls.push(url);
+ location.hash = "";
+ },
+ };
+}
+
+describe("canonicalInviteCode", () => {
+ it("keeps the canonical form", () => {
+ expect(canonicalInviteCode(CODE)).toBe(CODE);
+ });
+
+ it("accepts the host's hand-typing: case, dashes, spaces, prefix, confusables", () => {
+ for (const typed of [
+ "cnr-7k3q-hm9p-2xab-cdef",
+ "CNR7K3QHM9P2XABCDEF",
+ "7K3QHM9P2XABCDEF",
+ "7k3q hm9p 2xab cdef",
+ " CNR - 7K3Q - HM9P - 2XAB - CDEF ",
+ "7K3Q-HM9P-2XAB-CDEF",
+ ]) {
+ expect(canonicalInviteCode(typed), typed).toBe(CODE);
+ }
+ // I and L read as 1, O as 0, exactly as the host folds them.
+ expect(canonicalInviteCode("cnr iiii oooo llll oooo")).toBe("CNR-1111-0000-1111-0000");
+ });
+
+ it("rejects what is not a code", () => {
+ for (const junk of [
+ "",
+ "octocat",
+ "CNR-123",
+ "CNR-7K3Q-HM9P-2XAB-CDE",
+ "CNR-7K3Q-HM9P-2XAB-CDEFG",
+ "CNR-7K3Q-HM9P-2XAB-CDE!",
+ "CNR-7K3Q-HM9P-2XAB-CDEU",
+ "XYZ-7K3Q-HM9P-2XAB-CDEF",
+ // Only ASCII folds: ß must not upper-case into a valid "SS".
+ "CNR-7K3Q-HM9P-2XAB-CDß",
+ ]) {
+ expect(canonicalInviteCode(junk), junk).toBeNull();
+ }
+ });
+
+ it("hashes to what the host publishes: SHA-256 of the canonical string", () => {
+ // The relay hashes the decoded `invite` value as is; the host stores
+ // and publishes sha256(canonical). A lowercase or dashless form would
+ // hash to something no published invite carries.
+ const published = createHash("sha256").update(CODE).digest("hex");
+ const typed = canonicalInviteCode("cnr7k3qhm9p2xabcdef") as string;
+ expect(createHash("sha256").update(typed).digest("hex")).toBe(published);
+ expect(createHash("sha256").update("cnr7k3qhm9p2xabcdef").digest("hex")).not.toBe(published);
+ });
+});
+
+describe("inviteFormError", () => {
+ it("passes a code in any accepted form and names the format otherwise", () => {
+ expect(inviteFormError(CODE)).toBeNull();
+ expect(inviteFormError("7k3q hm9p 2xab cdef")).toBeNull();
+ expect(inviteFormError("")).toBe(INVITE_FORMAT_ERROR);
+ expect(inviteFormError("CNR-7K3Q")).toBe(INVITE_FORMAT_ERROR);
+ });
+});
+
+describe("takeInviteFragment", () => {
+ it("reads #invite= and strips the fragment in place", () => {
+ const location = { pathname: "/g/1001/76805a63a6980c18", search: "", hash: `#invite=${CODE}` };
+ const history = fakeHistory(location);
+ expect(takeInviteFragment(location, history)).toBe(CODE);
+ expect(history.calls).toEqual(["/g/1001/76805a63a6980c18"]);
+ });
+
+ it("decodes a percent-encoded code and keeps the query", () => {
+ const location = {
+ pathname: "/g/1001/76805a63a6980c18",
+ search: "?x=1",
+ hash: `#invite=${encodeURIComponent("cnr 7k3q")}`,
+ };
+ const history = fakeHistory(location);
+ expect(takeInviteFragment(location, history)).toBe("cnr 7k3q");
+ expect(history.calls).toEqual(["/g/1001/76805a63a6980c18?x=1"]);
+ });
+
+ it("leaves a fragment without an invite alone", () => {
+ for (const hash of ["", "#", "#code=1", "#CNR-7K3Q"]) {
+ const location = { pathname: "/g/1001/76805a63a6980c18", search: "", hash };
+ const history = fakeHistory(location);
+ expect(takeInviteFragment(location, history), hash).toBeNull();
+ expect(history.calls, hash).toEqual([]);
+ }
+ });
+});
+
+describe("invite storage", () => {
+ it("keeps one canonical code per link for this tab", () => {
+ const storage = new FakeStorage();
+ expect(loadInvite(LINK, storage)).toBeNull();
+ saveInvite(LINK, CODE, storage);
+ expect(loadInvite(LINK, storage)).toBe(CODE);
+ // Another host of the same owner has its own slot.
+ expect(loadInvite({ ...LINK, hostId: "0123456789abcdef" }, storage)).toBeNull();
+ forgetInvite(LINK, storage);
+ expect(loadInvite(LINK, storage)).toBeNull();
+ expect(storage.length).toBe(0);
+ });
+
+ it("re-checks a stored value and survives a storage that throws", () => {
+ const storage = new FakeStorage();
+ storage.setItem("canopy.remote.invite:1001/76805a63a6980c18", "not a code");
+ expect(loadInvite(LINK, storage)).toBeNull();
+ storage.failReads = true;
+ storage.failWrites = true;
+ expect(loadInvite(LINK, storage)).toBeNull();
+ expect(() => saveInvite(LINK, CODE, storage)).not.toThrow();
+ expect(() => forgetInvite(LINK, storage)).not.toThrow();
+ expect(loadInvite(LINK, null)).toBeNull();
+ });
+});
+
+describe("guest sign-in return", () => {
+ it("brings / back to the guest link the tab signed in from, once", () => {
+ const storage = new FakeStorage();
+ rememberGuestReturn("/g/1001/76805a63a6980c18", NOW, storage);
+ // The pathname only: no code ever rides this slot.
+ expect(storage.getItem(GUEST_RETURN_KEY)).not.toContain("CNR");
+ const history = fakeHistory({ pathname: "/", search: "", hash: "" });
+ expect(resumeGuestReturn({ pathname: "/" }, history, NOW + 60_000, storage)).toBe(true);
+ expect(history.calls).toEqual(["/g/1001/76805a63a6980c18"]);
+ expect(resumeGuestReturn({ pathname: "/" }, history, NOW + 60_000, storage)).toBe(false);
+ });
+
+ it("ignores other paths, stale or malformed entries, and non-guest links", () => {
+ const storage = new FakeStorage();
+ const history = fakeHistory({ pathname: "/", search: "", hash: "" });
+ rememberGuestReturn("/pair", NOW, storage);
+ expect(storage.length).toBe(0);
+ rememberGuestReturn("/g/1001/76805a63a6980c18", NOW, storage);
+ expect(resumeGuestReturn({ pathname: "/pair" }, history, NOW, storage)).toBe(false);
+ expect(resumeGuestReturn({ pathname: "/" }, history, NOW + GUEST_RETURN_TTL_MS + 1, storage)).toBe(
+ false,
+ );
+ storage.setItem(GUEST_RETURN_KEY, JSON.stringify({ path: "//evil.example/g/1/x", at: NOW }));
+ expect(resumeGuestReturn({ pathname: "/" }, history, NOW, storage)).toBe(false);
+ storage.setItem(GUEST_RETURN_KEY, "{");
+ expect(resumeGuestReturn({ pathname: "/" }, history, NOW, storage)).toBe(false);
+ expect(history.calls).toEqual([]);
+ expect(storage.length).toBe(0);
+ storage.failReads = true;
+ expect(resumeGuestReturn({ pathname: "/" }, history, NOW, storage)).toBe(false);
+ });
+});
diff --git a/apps/remote/web/test/guest-views.test.ts b/apps/remote/web/test/guest-views.test.ts
index 6b79eee..08b9899 100644
--- a/apps/remote/web/test/guest-views.test.ts
+++ b/apps/remote/web/test/guest-views.test.ts
@@ -36,6 +36,10 @@ class FakeHandle implements TerminalHandle {
callback();
return () => {};
};
+ resizes: { cols: number; rows: number }[] = [];
+ resize = (cols: number, rows: number): void => {
+ this.resizes.push({ cols, rows });
+ };
dispose = (): void => {
this.disposed = true;
};
@@ -256,6 +260,19 @@ describe("guest session view", () => {
expect(handles[0]?.disposed).toBe(true);
});
+ it("follows the host's resized frame in place", () => {
+ const { view, handles } = setup();
+ view.showSession("s1", "build");
+ view.ensureTerminal("s1", 80, 24);
+ view.sessionResized("s2", 90, 20);
+ view.sessionResized("s1", 120, 40);
+ expect(handles).toHaveLength(1);
+ expect(handles[0]?.resizes).toEqual([{ cols: 120, rows: 40 }]);
+ // The snapshot at the new size keeps the same terminal.
+ view.ensureTerminal("s1", 120, 40);
+ expect(handles).toHaveLength(1);
+ });
+
it("sends typed text and key-bar keys as input", () => {
const { root, view, handles, inputs } = setup();
view.showSession("s1", "build");
diff --git a/apps/remote/web/test/guest.spec.ts b/apps/remote/web/test/guest.spec.ts
index 9aa9ef0..b09dfac 100644
--- a/apps/remote/web/test/guest.spec.ts
+++ b/apps/remote/web/test/guest.spec.ts
@@ -106,3 +106,49 @@ test("screenshots a guest inside a granted session", async ({ page }, testInfo)
await expect.poll(() => terminalText(page)).toContain("hello from the host");
await page.screenshot({ path: testInfo.outputPath("guest-session.png") });
});
+
+// A guest link without #invite= (or a code the relay refused) lands on
+// the invite form: one labelled field tied to its hint and its error,
+// checked against the code format, sent in the canonical form.
+test("a guest without a code types it into the invite form", async ({ page }) => {
+ await page.goto("/fake.html?screen=guest&invite=ask");
+ const field = page.getByLabel("Invite code");
+ await expect(field).toBeVisible();
+ await expect(field).toHaveAttribute("aria-describedby", "guest-invite-hint guest-invite-error");
+ // Not a code: the error shows, the field is marked invalid and keeps focus.
+ await field.fill("CNR-7K3Q");
+ await page.getByRole("button", { name: "Join" }).click();
+ await expect(page.locator("#guest-invite-error")).toContainText("not an invite code");
+ await expect(field).toHaveAttribute("aria-invalid", "true");
+ await expect(field).toBeFocused();
+ expect(await page.evaluate(() => window.__fake.guestInvites?.() ?? [])).toEqual([]);
+ // Lowercase with spaces is fine: the host's canonical form is sent.
+ await field.fill("7k3q hm9p 2xab cdef");
+ await field.press("Enter");
+ await expect(page.locator('[data-session="smoke"]')).toBeVisible();
+ expect(await page.evaluate(() => window.__fake.guestInvites?.() ?? [])).toEqual(["CNR-7K3Q-HM9P-2XAB-CDEF"]);
+});
+
+test("a refused invite asks again with the reason, never the pairing message", async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 360, height: 640 });
+ await page.goto("/fake.html?screen=guest&invite=refuse");
+ await page.getByLabel("Invite code").fill("CNR-7K3Q-HM9P-2XAB-CDEF");
+ await page.getByRole("button", { name: "Join" }).click();
+ const notice = page.locator("#guest-invite-notice");
+ await expect(notice).toContainText("did not accept this invite code");
+ await expect(notice).toHaveAttribute("role", "alert");
+ await expect(page.locator("body")).not.toContainText("no longer paired");
+ // Prefilled, so a retry with a fresh code is one edit away.
+ await expect(page.getByLabel("Invite code")).toHaveValue("CNR-7K3Q-HM9P-2XAB-CDEF");
+ const overflow = await page.evaluate(() => {
+ const doc = document.scrollingElement ?? document.documentElement;
+ return doc.scrollWidth - doc.clientWidth;
+ });
+ expect(overflow).toBeLessThanOrEqual(1);
+ const join = await page.getByRole("button", { name: "Join" }).boundingBox();
+ expect(join?.height ?? 0).toBeGreaterThanOrEqual(44);
+ await page.screenshot({ path: testInfo.outputPath("guest-invite-refused.png") });
+ await page.getByLabel("Invite code").fill("CNR-1111-0000-1111-0000");
+ await page.getByRole("button", { name: "Join" }).click();
+ await expect(page.locator('[data-session="smoke"]')).toBeVisible();
+});
diff --git a/apps/remote/web/test/guest.test.ts b/apps/remote/web/test/guest.test.ts
index 2c254c0..fe31c5e 100644
--- a/apps/remote/web/test/guest.test.ts
+++ b/apps/remote/web/test/guest.test.ts
@@ -2,12 +2,14 @@
import { afterEach, describe, expect, it } from "vitest";
import { b64decode, b64encode, generateDeviceKey, importSpkiVerifyKey } from "../src/crypto.js";
import type { DeviceKey } from "../src/crypto.js";
-import { GuestPeer, createGuestSession, guestRelayUrl, parseGuestLink } from "../src/guest.js";
+import { GuestPeer, createGuestSession, guestRelayUrl, mountGuest, parseGuestLink } from "../src/guest.js";
+import type { GuestMountDeps } from "../src/guest.js";
+import { INVITE_FORMAT_ERROR, INVITE_REFUSED_MESSAGE, INVITE_SPENT_MESSAGE } from "../src/guest-invite.js";
import type { GuestHostRecord } from "../src/guest-store.js";
import { guestHostKey } from "../src/guest-store.js";
import { connectFailMessage } from "../src/peer.js";
import type { PeerEvent } from "../src/peer.js";
-import { defaultRelayUrl } from "../src/relay.js";
+import { RelaySocket, defaultRelayUrl } from "../src/relay.js";
import type { TerminalFactory, TerminalHandle } from "../src/terminal.js";
import {
connectRelay,
@@ -15,12 +17,14 @@ import {
createRelayHarness,
createTestHost,
createTurnFetcher,
+ FakeStorage,
+ FakeWebSocket,
flush,
signTestAnswer,
textOf,
waitForSend,
} from "./fakes.js";
-import type { FakeWebSocket, PeerHarness, RelayHarness, TestHost } from "./fakes.js";
+import type { PeerHarness, RelayHarness, TestHost } from "./fakes.js";
const ANSWER_SDP = "v=0\r\no=- 9 9 IN IP4 127.0.0.1\r\n";
const OWNER_UID = 7;
@@ -60,8 +64,17 @@ describe("parseGuestLink", () => {
});
describe("guestRelayUrl", () => {
- it("is the device door with the owner as guest_of", () => {
- expect(guestRelayUrl(OWNER_UID)).toBe(`${defaultRelayUrl()}?guest_of=${OWNER_UID}`);
+ it("is the device door with the owner as guest_of and the invite code", () => {
+ expect(guestRelayUrl(OWNER_UID, "CNR-7K3Q-HM9P-2XAB-CDEF")).toBe(
+ `${defaultRelayUrl()}?guest_of=${OWNER_UID}&invite=CNR-7K3Q-HM9P-2XAB-CDEF`,
+ );
+ });
+
+ it("encodes the code as one query value", () => {
+ const url = new URL(guestRelayUrl(OWNER_UID, "a&b=c d#e"));
+ expect(url.searchParams.get("guest_of")).toBe(String(OWNER_UID));
+ expect(url.searchParams.get("invite")).toBe("a&b=c d#e");
+ expect(url.hash).toBe("");
});
});
@@ -563,3 +576,221 @@ describe("createGuestSession", () => {
expect(textOf(collected)).toBe("fresh");
});
});
+
+describe("mountGuest", () => {
+ const CODE = "CNR-7K3Q-HM9P-2XAB-CDEF";
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ interface Mounted {
+ root: HTMLElement;
+ sockets: FakeWebSocket[];
+ storage: FakeStorage;
+ replaced: (string | undefined)[];
+ host: TestHost;
+ device: DeviceKey;
+ peerHarness: PeerHarness;
+ done: Promise;
+ }
+
+ async function mount(options: {
+ hash?: string;
+ stored?: string;
+ login?: string | null;
+ online?: boolean;
+ }): Promise {
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ const host = await createTestHost();
+ const device = await generateDeviceKey();
+ const sockets: FakeWebSocket[] = [];
+ const storage = new FakeStorage();
+ const pathname = `/g/${OWNER_UID}/${host.record.host_id}`;
+ if (options.stored !== undefined) {
+ storage.setItem(`canopy.remote.invite:${OWNER_UID}/${host.record.host_id}`, options.stored);
+ }
+ const location = { pathname, search: "", hash: options.hash ?? "" };
+ const replaced: (string | undefined)[] = [];
+ const peerHarness = createPeerHarness();
+ const pins = new Map();
+ const deps: Partial = {
+ location,
+ history: {
+ replaceState: (_data, _unused, url) => {
+ replaced.push(url);
+ location.hash = "";
+ },
+ },
+ storage,
+ fetchLogin: async () => (options.login === undefined ? "bob" : options.login),
+ signOut: async () => {},
+ ensureDeviceKey: async () => device,
+ clearDevice: async () => {},
+ createRelay: (url) =>
+ new RelaySocket(url, (openUrl) => {
+ const ws = new FakeWebSocket(openUrl);
+ sockets.push(ws);
+ return ws as unknown as WebSocket;
+ }),
+ isOnline: () => options.online ?? true,
+ peer: {
+ fetchTurn: createTurnFetcher(true).fetchTurn,
+ createConnection: peerHarness.factory,
+ nowMs: () => 1793100000_000,
+ getPin: async (ownerUid, hostId) => pins.get(guestHostKey(ownerUid, hostId)) ?? null,
+ savePin: async (record) => {
+ pins.set(record.key, record);
+ },
+ },
+ };
+ const done = mountGuest(root, deps);
+ return { root, sockets, storage, replaced, host, device, peerHarness, done };
+ }
+
+ async function until(check: () => boolean): Promise {
+ for (let i = 0; i < 200 && !check(); i += 1) {
+ await flush();
+ }
+ expect(check()).toBe(true);
+ }
+
+ function inviteKey(m: Mounted): string {
+ return `canopy.remote.invite:${OWNER_UID}/${m.host.record.host_id}`;
+ }
+
+ function submit(m: Mounted, value: string): void {
+ const input = m.root.querySelector("#guest-invite-code");
+ if (!input) {
+ throw new Error("no invite field");
+ }
+ input.value = value;
+ m.root.querySelector("#guest-invite-form")?.requestSubmit();
+ }
+
+ it("takes the fragment's code, strips it and opens the door with the canonical form", async () => {
+ const m = await mount({ hash: "#invite=cnr7k3qhm9p2xabcdef" });
+ // Stripped before anything else ran: path only, no fragment.
+ expect(m.replaced).toEqual([`/g/${OWNER_UID}/${m.host.record.host_id}`]);
+ expect(m.storage.getItem(inviteKey(m))).toBe(CODE);
+ await until(() => m.sockets.length === 1);
+ expect(m.sockets[0]?.url).toBe(`${defaultRelayUrl()}?guest_of=${OWNER_UID}&invite=${CODE}`);
+ m.sockets[0]?.fail();
+ await m.done;
+ });
+
+ it("asks for the code when the link carries none, and validates it", async () => {
+ const m = await mount({});
+ await m.done;
+ const input = m.root.querySelector("#guest-invite-code");
+ expect(input).not.toBeNull();
+ expect(input?.closest("label")?.textContent).toContain("Invite code");
+ expect(input?.getAttribute("aria-describedby")).toBe("guest-invite-hint guest-invite-error");
+ expect(m.sockets).toHaveLength(0);
+ // Not a code: the error is tied to the field and nothing is sent.
+ submit(m, "CNR-7K3Q");
+ const error = m.root.querySelector("#guest-invite-error");
+ expect(error?.hidden).toBe(false);
+ expect(error?.getAttribute("role")).toBe("alert");
+ expect(error?.textContent).toBe(INVITE_FORMAT_ERROR);
+ expect(input?.getAttribute("aria-invalid")).toBe("true");
+ expect(m.sockets).toHaveLength(0);
+ // Typed lowercase with spaces: sent canonical, kept for the tab.
+ submit(m, "7k3q hm9p 2xab cdef");
+ await until(() => m.sockets.length === 1);
+ expect(new URL(m.sockets[0]?.url ?? "").searchParams.get("invite")).toBe(CODE);
+ expect(m.storage.getItem(inviteKey(m))).toBe(CODE);
+ m.sockets[0]?.fail();
+ await until(() => m.root.querySelector("#guest-invite-notice") !== null);
+ });
+
+ it("reconnects in the same tab with the stored code, no form", async () => {
+ const m = await mount({ stored: CODE });
+ await until(() => m.sockets.length === 1);
+ expect(new URL(m.sockets[0]?.url ?? "").searchParams.get("invite")).toBe(CODE);
+ expect(m.root.querySelector("#guest-invite-form")).toBeNull();
+ m.sockets[0]?.fail();
+ await m.done;
+ });
+
+ it("turns a refused door (bad_invite) into the form again, prefilled, never 'no longer paired'", async () => {
+ const m = await mount({ stored: CODE });
+ await until(() => m.sockets.length === 1);
+ // The relay answers 403 bad_invite: the browser sees only a close
+ // before the socket ever opened.
+ m.sockets[0]?.fail();
+ await m.done;
+ const notice = m.root.querySelector("#guest-invite-notice");
+ expect(notice?.textContent).toBe(INVITE_REFUSED_MESSAGE);
+ expect(notice?.getAttribute("role")).toBe("alert");
+ expect(m.root.textContent).not.toContain("no longer paired");
+ expect(m.root.querySelector("#guest-invite-code")?.value).toBe(CODE);
+ // The refused code leaves the tab; a fresh one opens a fresh door.
+ expect(m.storage.getItem(inviteKey(m))).toBeNull();
+ submit(m, "CNR-1111-0000-1111-0000");
+ await until(() => m.sockets.length === 2);
+ expect(new URL(m.sockets[1]?.url ?? "").searchParams.get("invite")).toBe("CNR-1111-0000-1111-0000");
+ m.sockets[1]?.fail();
+ await until(() => m.root.querySelector("#guest-invite-notice") !== null);
+ });
+
+ it("keeps the code and says the relay is unreachable when the browser is offline", async () => {
+ const m = await mount({ stored: CODE, online: false });
+ await until(() => m.sockets.length === 1);
+ m.sockets[0]?.fail();
+ await m.done;
+ expect(m.root.querySelector("#guest-invite-form")).toBeNull();
+ expect(m.root.textContent).toContain("Could not reach the relay");
+ expect(m.storage.getItem(inviteKey(m))).toBe(CODE);
+ });
+
+ it("prefills a malformed fragment code with a notice instead of connecting", async () => {
+ const m = await mount({ hash: "#invite=CNR-7K3Q" });
+ await m.done;
+ expect(m.replaced).toHaveLength(1);
+ expect(m.sockets).toHaveLength(0);
+ expect(m.root.querySelector("#guest-invite-notice")?.textContent).toContain("malformed");
+ expect(m.root.querySelector("#guest-invite-code")?.value).toBe("CNR-7K3Q");
+ });
+
+ it("keeps the fragment's code through the sign-in gate", async () => {
+ const m = await mount({ hash: `#invite=${CODE}`, login: null });
+ await m.done;
+ expect(m.replaced).toHaveLength(1);
+ expect(m.storage.getItem(inviteKey(m))).toBe(CODE);
+ expect(m.root.querySelector("#signin-go")).not.toBeNull();
+ expect(m.sockets).toHaveLength(0);
+ });
+
+ it("says the code is spent when the door refuses it after the host let this tab in", async () => {
+ const m = await mount({ stored: CODE });
+ await until(() => m.sockets.length === 1);
+ const ws = m.sockets[0] as FakeWebSocket;
+ ws.open();
+ await waitForSend(ws);
+ const offer = ws.envelopes().find((e) => e.body.t === "offer");
+ const offerSig = (offer?.body as { sig: string }).sig;
+ const sig = await signTestAnswer(m.host.privateKey, m.device.deviceId, offerSig, ANSWER_SDP);
+ ws.receive(`h:${m.host.record.host_id}`, {
+ t: "answer",
+ sdp: ANSWER_SDP,
+ sig,
+ host_spki: m.host.record.host_spki,
+ });
+ await until(() => m.peerHarness.latest().remoteDescription !== null);
+ m.peerHarness.latest().firstChannel().remoteOpen();
+ await m.done;
+ await until(() => m.root.querySelector("#guest-list") !== null);
+ // The peer and the relay socket drop; Retry finds the door shut: the
+ // first accepted offer burned the code.
+ m.peerHarness.latest().firstChannel().close();
+ ws.close(1006);
+ await until(() => m.root.querySelector("#guest-retry") !== null);
+ m.root.querySelector("#guest-retry")?.click();
+ await until(() => m.sockets.length === 2);
+ m.sockets[1]?.fail();
+ await until(() => m.root.querySelector("#guest-invite-notice") !== null);
+ expect(m.root.querySelector("#guest-invite-notice")?.textContent).toBe(INVITE_SPENT_MESSAGE);
+ });
+});
diff --git a/apps/remote/web/test/protocol.test.ts b/apps/remote/web/test/protocol.test.ts
index 52a66e1..037d707 100644
--- a/apps/remote/web/test/protocol.test.ts
+++ b/apps/remote/web/test/protocol.test.ts
@@ -33,6 +33,7 @@ import {
parseHello,
parseOk,
parseOut,
+ parseResized,
parseSessionAdded,
parseSessionItem,
parseSessionRemoved,
@@ -222,6 +223,34 @@ describe("frame parsers", () => {
expect(parseExited({ t: "exited", id: "a" })).toBeNull();
});
+ it("parses resized frames with a valid size only", () => {
+ expect(parseResized({ t: "resized", id: "a", cols: 120, rows: 40 })).toEqual({
+ id: "a",
+ cols: 120,
+ rows: 40,
+ });
+ // The host's size bounds, 1..=65535 on both axes.
+ expect(parseResized({ t: "resized", id: "a", cols: 1, rows: 65535 })).toEqual({
+ id: "a",
+ cols: 1,
+ rows: 65535,
+ });
+ for (const bad of [
+ { t: "resized", id: "a", cols: 0, rows: 40 },
+ { t: "resized", id: "a", cols: 120, rows: 65536 },
+ { t: "resized", id: "a", cols: 120.5, rows: 40 },
+ { t: "resized", id: "a", cols: "120", rows: 40 },
+ { t: "resized", id: "a", cols: 120 },
+ { t: "resized", id: "", cols: 120, rows: 40 },
+ { t: "resized", cols: 120, rows: 40 },
+ { t: "resize", id: "a", cols: 120, rows: 40 },
+ null,
+ "resized",
+ ]) {
+ expect(parseResized(bad)).toBeNull();
+ }
+ });
+
it("parses out frames and rejects bad base64", () => {
expect(parseOut({ t: "out", id: "a", seq: 9, data: "eQ==" })).toEqual({
id: "a",
diff --git a/apps/remote/web/test/router.test.ts b/apps/remote/web/test/router.test.ts
index 11d1ea2..9f05ba7 100644
--- a/apps/remote/web/test/router.test.ts
+++ b/apps/remote/web/test/router.test.ts
@@ -24,6 +24,7 @@ interface Harness {
controller: SessionController;
written: Uint8Array[];
terminals: { id: string; cols: number; rows: number }[];
+ resizes: { id: string; cols: number; rows: number }[];
exits: number[];
spawned: { req: string; id: string }[];
composed: { req: string; text: string }[];
@@ -38,6 +39,7 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames):
const catalog = new CatalogStore();
const written: Uint8Array[] = [];
const terminals: Harness["terminals"] = [];
+ const resizes: Harness["resizes"] = [];
const exits: number[] = [];
const spawned: Harness["spawned"] = [];
const composed: Harness["composed"] = [];
@@ -48,6 +50,9 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames):
ensureTerminal: (id: string, cols: number, rows: number) => {
terminals.push({ id, cols, rows });
},
+ sessionResized: (id: string, cols: number, rows: number) => {
+ resizes.push({ id, cols, rows });
+ },
showSessionExited: (code: number) => {
exits.push(code);
},
@@ -92,6 +97,7 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames):
controller,
written,
terminals,
+ resizes,
exits,
spawned,
composed,
@@ -255,6 +261,39 @@ describe("createFrameRouter", () => {
expect(h.catalog.availableClis.map((cli) => cli.cli)).toEqual(["muse", "opencode"]);
});
+ it("routes the attached session's resized frame to the shell, only that one", () => {
+ const h = setup();
+ // Nothing attached: dropped.
+ h.hostSend({ t: "resized", id: "s1", cols: 100, rows: 30 });
+ expect(h.resizes).toEqual([]);
+ h.controller.open("s1");
+ h.hostSend({ t: "resized", id: "s1", cols: 100, rows: 30 });
+ h.hostSend({ t: "resized", id: "s2", cols: 90, rows: 20 });
+ // Malformed: dropped like an unknown frame, never an error.
+ h.hostSend({ t: "resized", id: "s1", cols: 0, rows: 30 });
+ h.hostSend({ t: "resized", id: "s1", cols: "100", rows: 30 });
+ expect(h.resizes).toEqual([{ id: "s1", cols: 100, rows: 30 }]);
+ expect(h.sheetErrors).toEqual([]);
+ });
+
+ it("drops resized for a shell that does not follow it", () => {
+ const h = setup();
+ const bare = createFrameRouter({
+ store: h.store,
+ controller: h.controller,
+ catalog: h.catalog,
+ shell: {
+ ensureTerminal: () => {},
+ sheetSpawned: () => {},
+ sheetComposed: () => {},
+ sheetOk: () => {},
+ sheetError: () => {},
+ },
+ });
+ h.controller.open("s1");
+ expect(() => bare.handleFrame({ t: "resized", id: "s1", cols: 100, rows: 30 })).not.toThrow();
+ });
+
it("ignores unknown frames without throwing", () => {
const h = setup();
expect(() => {
diff --git a/apps/remote/web/test/serve.spec.ts b/apps/remote/web/test/serve.spec.ts
index 3f94cc1..bf66345 100644
--- a/apps/remote/web/test/serve.spec.ts
+++ b/apps/remote/web/test/serve.spec.ts
@@ -252,15 +252,15 @@ test("sign-in wears the header banner and a one-row colophon at 1280x900", async
const box = await banner.boundingBox();
expect(box).not.toBeNull();
expect(Math.abs((box?.height ?? 0) - 160)).toBeLessThanOrEqual(5);
- // The wizard green gradient survives the relay's CSP: the ink is a
- // `fill` presentation attribute (or banner.css), never an inline style.
- // A refused load would log here.
+ // The teal gradient survives the relay's CSP: the ink is a `fill`
+ // presentation attribute (or banner.css), never an inline style. A
+ // refused load would log here.
const inks = await page
.locator(".topbar .look-banner-line")
.evaluateAll((groups) => groups.map((group) => getComputedStyle(group).fill));
expect(inks).toHaveLength(11);
- expect(inks[0]).toBe("rgb(157, 207, 161)");
- expect(inks[10]).toBe("rgb(12, 82, 18)");
+ expect(inks[0]).toBe("rgb(82, 255, 246)");
+ expect(inks[10]).toBe("rgb(0, 102, 97)");
expect(refused.filter((text) => text.includes("Content Security Policy"))).toEqual([]);
const tops = await page
.locator("footer.look-colophon nav a")
diff --git a/apps/remote/web/test/term-controls.test.ts b/apps/remote/web/test/term-controls.test.ts
index 7c568fc..d7906f4 100644
--- a/apps/remote/web/test/term-controls.test.ts
+++ b/apps/remote/web/test/term-controls.test.ts
@@ -3,10 +3,22 @@
// browser can honour it, toggling through the Fullscreen API and
// following fullscreenchange (maximize when windowed, minimize while
// fullscreen). happy-dom ships no Fullscreen API, so every leg stubs it.
-import { afterEach, describe, expect, it, vi } from "vitest";
+// The control-size toggle: the size that fits this device's panel, sent
+// and applied locally on, re-asserted (debounced) while on, never off.
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { icon } from "../src/icons-dom.js";
-import { termControlsHtml, wireTermControls } from "../src/term-controls.js";
-import type { TermControlsDeps } from "../src/term-controls.js";
+import {
+ SIZE_DEBOUNCE_MS,
+ SIZE_TAKEN_NOTE,
+ latestHidden,
+ termControlsHtml,
+ termLatestHtml,
+ termSizeHtml,
+ wireTermControls,
+} from "../src/term-controls.js";
+import type { TermControls, TermControlsDeps, TermControlsView } from "../src/term-controls.js";
+import type { TermSize } from "../src/terminal.js";
+import { readControlSize, writeControlSize } from "../src/terminal-prefs.js";
/** The icon markup as the DOM serializes it (paths gain close tags). */
function rendered(name: Parameters[0]): string {
@@ -20,8 +32,8 @@ function deps(root: HTMLElement): TermControlsDeps {
root,
sessionId: () => "s1",
terminalView: () => null,
- sessionSize: () => ({ cols: 80, rows: 24 }),
onControlSize: () => {},
+ onLocalSize: () => {},
onBuilder: () => {},
};
}
@@ -147,3 +159,278 @@ describe("term fullscreen button", () => {
expect(button?.innerHTML).toBe(rendered("maximize"));
});
});
+
+/** The view slice the size toggle drives, with a panel size the test sets. */
+class StubView implements TermControlsView {
+ fitted = true;
+ isAlternate = false;
+ owner: boolean[] = [];
+ fits: TermSize | null;
+ private release: () => void = () => {};
+ private readonly readyPromise: Promise;
+
+ constructor(fits: TermSize | null, open = true) {
+ this.fits = fits;
+ this.fitted = open;
+ this.readyPromise = open
+ ? Promise.resolve()
+ : new Promise((resolve) => {
+ this.release = resolve;
+ });
+ }
+
+ zoomIn = (): void => {};
+ zoomOut = (): void => {};
+ scrollToLatest = (): void => {};
+ ready = (): Promise => this.readyPromise;
+ ownedSize = (): TermSize | null => this.fits;
+ setSizeOwner = (owner: boolean): void => {
+ this.owner.push(owner);
+ };
+
+ open(): void {
+ this.fitted = true;
+ this.release();
+ }
+}
+
+describe("control-size toggle", () => {
+ let root: HTMLDivElement;
+ let view: StubView | null;
+ let sent: TermSize[];
+ let local: TermSize[];
+ let controls: TermControls;
+
+ const toggle = (): HTMLInputElement => {
+ const el = root.querySelector("#term-control-size");
+ if (!el) {
+ throw new Error("missing #term-control-size");
+ }
+ return el;
+ };
+
+ const flip = (checked: boolean): void => {
+ toggle().checked = checked;
+ toggle().dispatchEvent(new Event("change", { bubbles: true }));
+ };
+
+ const mount = (): void => {
+ root = document.createElement("div");
+ document.body.appendChild(root);
+ root.innerHTML = `${termControlsHtml()}${termSizeHtml("s1")}`;
+ controls = wireTermControls({
+ ...deps(root),
+ terminalView: () => view,
+ onControlSize: (cols, rows) => sent.push({ cols, rows }),
+ onLocalSize: (cols, rows) => local.push({ cols, rows }),
+ });
+ };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ localStorage.clear();
+ view = new StubView({ cols: 44, rows: 19 });
+ sent = [];
+ local = [];
+ mount();
+ });
+
+ afterEach(() => {
+ controls.dispose();
+ vi.useRealTimers();
+ document.body.innerHTML = "";
+ localStorage.clear();
+ });
+
+ it("sends and applies the size that fits the panel when turned on", () => {
+ flip(true);
+ expect(readControlSize("s1")).toBe(true);
+ expect(view?.owner).toEqual([true]);
+ // The panel's own size, never the host's: the local terminal takes it
+ // too, so it renders at scale 1 instead of being fitted.
+ expect(sent).toEqual([{ cols: 44, rows: 19 }]);
+ expect(local).toEqual([{ cols: 44, rows: 19 }]);
+ });
+
+ it("re-sends once, debounced, while the panel keeps changing", () => {
+ flip(true);
+ view!.fits = { cols: 90, rows: 30 };
+ globalThis.dispatchEvent(new Event("resize"));
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS - 50);
+ globalThis.dispatchEvent(new Event("orientationchange"));
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS - 1);
+ expect(sent).toHaveLength(1);
+ document.dispatchEvent(new Event("fullscreenchange"));
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS);
+ expect(sent).toEqual([
+ { cols: 44, rows: 19 },
+ { cols: 90, rows: 30 },
+ ]);
+ expect(local).toEqual(sent);
+ // A zoom reaches the same debounce through the shell.
+ view!.fits = { cols: 72, rows: 24 };
+ controls.schedule();
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS);
+ expect(sent.at(-1)).toEqual({ cols: 72, rows: 24 });
+ });
+
+ it("never re-sends a size that did not change", () => {
+ flip(true);
+ globalThis.dispatchEvent(new Event("resize"));
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS);
+ controls.schedule();
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS);
+ expect(sent).toHaveLength(1);
+ });
+
+ it("stops asserting and follows the host again when turned off", () => {
+ flip(true);
+ flip(false);
+ expect(readControlSize("s1")).toBe(false);
+ expect(view?.owner).toEqual([true, false]);
+ view!.fits = { cols: 90, rows: 30 };
+ globalThis.dispatchEvent(new Event("resize"));
+ controls.schedule();
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS * 2);
+ expect(sent).toHaveLength(1);
+ });
+
+ it("re-asserts a remembered toggle on mount, even an unchanged size", () => {
+ controls.reassert();
+ // Off: nothing to re-assert.
+ expect(sent).toEqual([]);
+ writeControlSize("s1", true);
+ controls.reassert();
+ expect(toggle().checked).toBe(true);
+ expect(sent).toEqual([{ cols: 44, rows: 19 }]);
+ // A remount (reconnect) may face a host at any size: send it again.
+ controls.reassert();
+ expect(sent).toEqual([
+ { cols: 44, rows: 19 },
+ { cols: 44, rows: 19 },
+ ]);
+ });
+
+ it("waits for a terminal behind the font gate before measuring", async () => {
+ const gated = new StubView({ cols: 50, rows: 20 }, false);
+ view = gated;
+ flip(true);
+ expect(sent).toEqual([]);
+ gated.open();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(sent).toEqual([{ cols: 50, rows: 20 }]);
+ });
+
+ it("retries a panel that is not measured yet", () => {
+ view!.fits = null;
+ flip(true);
+ expect(sent).toEqual([]);
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS);
+ view!.fits = { cols: 60, rows: 22 };
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS);
+ expect(sent).toEqual([{ cols: 60, rows: 22 }]);
+ });
+
+ it("asserts nothing without a terminal, until the mount re-asserts", () => {
+ view = null;
+ flip(true);
+ expect(sent).toEqual([]);
+ view = new StubView({ cols: 44, rows: 19 });
+ controls.reassert();
+ expect(sent).toEqual([{ cols: 44, rows: 19 }]);
+ });
+
+ it("stops watching the panel once disposed", () => {
+ flip(true);
+ controls.dispose();
+ view!.fits = { cols: 90, rows: 30 };
+ globalThis.dispatchEvent(new Event("resize"));
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS * 2);
+ expect(sent).toHaveLength(1);
+ });
+
+ const note = (): string => root.querySelector(".term-size-note")?.textContent ?? "";
+
+ it("follows the host's resized frame while off", () => {
+ controls.handleResized(120, 40);
+ expect(local).toEqual([{ cols: 120, rows: 40 }]);
+ expect(sent).toEqual([]);
+ expect(toggle().checked).toBe(false);
+ });
+
+ it("keeps the size when the host echoes this device's own", () => {
+ flip(true);
+ controls.handleResized(44, 19);
+ expect(toggle().checked).toBe(true);
+ expect(readControlSize("s1")).toBe(true);
+ expect(local).toEqual([{ cols: 44, rows: 19 }]);
+ });
+
+ it("never mistakes a slow echo of an older size for a takeover", () => {
+ flip(true);
+ view!.fits = { cols: 90, rows: 30 };
+ globalThis.dispatchEvent(new Event("resize"));
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS);
+ expect(sent).toHaveLength(2);
+ // The host answers both, the older one first.
+ controls.handleResized(44, 19);
+ controls.handleResized(90, 30);
+ expect(toggle().checked).toBe(true);
+ expect(note()).not.toBe(SIZE_TAKEN_NOTE);
+ });
+
+ it("lets go without fighting when another device takes the size over", () => {
+ flip(true);
+ expect(sent).toEqual([{ cols: 44, rows: 19 }]);
+ controls.handleResized(120, 40);
+ // Off and remembered off, said in the note; the local terminal follows
+ // the new size and nothing is sent back.
+ expect(toggle().checked).toBe(false);
+ expect(readControlSize("s1")).toBe(false);
+ expect(view?.owner).toEqual([true, false]);
+ expect(note()).toBe(SIZE_TAKEN_NOTE);
+ expect(root.querySelector(".term-size-note")?.classList.contains("is-notice")).toBe(true);
+ expect(local.at(-1)).toEqual({ cols: 120, rows: 40 });
+ expect(sent).toEqual([{ cols: 44, rows: 19 }]);
+ // No longer watching: a panel change sends nothing.
+ globalThis.dispatchEvent(new Event("resize"));
+ vi.advanceTimersByTime(SIZE_DEBOUNCE_MS * 2);
+ expect(sent).toHaveLength(1);
+ // Taking it back clears the notice and asserts again.
+ flip(true);
+ expect(note()).toBe("Only one client should control the size.");
+ expect(sent).toHaveLength(2);
+ });
+
+ it("follows a resize that lands before its own first assert", () => {
+ const gated = new StubView({ cols: 50, rows: 20 }, false);
+ view = gated;
+ flip(true);
+ controls.handleResized(120, 40);
+ expect(toggle().checked).toBe(true);
+ expect(local).toEqual([{ cols: 120, rows: 40 }]);
+ });
+});
+
+describe("the latest button", () => {
+ it("renders outside the toolbar, icon-only and named", () => {
+ const root = document.createElement("div");
+ root.innerHTML = termControlsHtml();
+ expect(root.querySelector("#term-latest")).toBeNull();
+ root.innerHTML = termLatestHtml();
+ const latest = root.querySelector("#term-latest");
+ expect(latest?.hidden).toBe(true);
+ expect(latest?.getAttribute("aria-label")).toBe("Scroll to the latest output");
+ expect(latest?.getAttribute("title")).toBe("Scroll to the latest output");
+ expect(latest?.textContent?.trim()).toBe("");
+ });
+
+ it("shows on the shell while not following, in fullscreen while scrolled down", () => {
+ expect(latestHidden(true, false, 0)).toBe(true);
+ expect(latestHidden(false, false, 0)).toBe(false);
+ expect(latestHidden(false, false, 300)).toBe(false);
+ expect(latestHidden(false, true, 0)).toBe(true);
+ expect(latestHidden(false, true, 1)).toBe(false);
+ });
+});
diff --git a/apps/remote/web/test/terminal-alt-screen.spec.ts b/apps/remote/web/test/terminal-alt-screen.spec.ts
index b2b9854..7eb979e 100644
--- a/apps/remote/web/test/terminal-alt-screen.spec.ts
+++ b/apps/remote/web/test/terminal-alt-screen.spec.ts
@@ -119,8 +119,10 @@ for (const viewport of [
// The full 80-column row arrived intact (start label, end marker).
expect(state.rows[11]).toContain("|END");
expect(state.rows[11]?.trimEnd().length).toBe(80);
- // Follow is off while fullscreen: the latest button shows.
- await expect(page.locator("#term-latest")).toBeVisible();
+ // Follow is off while fullscreen, but the whole frame fits with the
+ // pane at its top: nothing to jump to, so the floating latest button
+ // stays off the frame (and off the app's input line).
+ await expect(page.locator("#term-latest")).toBeHidden();
// The fitted scale is the whole-frame fit…
await expect
.poll(async () => {
diff --git a/apps/remote/web/test/terminal-view.test.ts b/apps/remote/web/test/terminal-view.test.ts
index 2999c3c..370d650 100644
--- a/apps/remote/web/test/terminal-view.test.ts
+++ b/apps/remote/web/test/terminal-view.test.ts
@@ -5,11 +5,16 @@ import {
classifyTouchGesture,
computeFitScale,
computeMinScale,
+ computeOwnedSize,
computeScale,
decayFlingVelocity,
flingVelocity,
MAX_ZOOM,
MIN_ZOOM,
+ OWNED_MAX_COLS,
+ OWNED_MAX_ROWS,
+ OWNED_MIN_COLS,
+ OWNED_MIN_ROWS,
shouldContinueFling,
shouldStartFling,
splitTouchScroll,
@@ -29,6 +34,8 @@ class FakeHandle implements TerminalHandle {
cols: number;
rows: number;
alternate = false;
+ baseScale?: number;
+ resizes: { cols: number; rows: number }[] = [];
private inputListeners: ((text: string) => void)[] = [];
private scrollListeners: (() => void)[] = [];
private bufferListeners: (() => void)[] = [];
@@ -69,6 +76,11 @@ class FakeHandle implements TerminalHandle {
callback();
return () => {};
};
+ resize = (cols: number, rows: number): void => {
+ this.cols = cols;
+ this.rows = rows;
+ this.resizes.push({ cols, rows });
+ };
dispose = (): void => {
this.disposed = true;
};
@@ -151,6 +163,32 @@ describe("scale math", () => {
});
});
+describe("owned size", () => {
+ it("fits whole cells into the panel", () => {
+ // 372 × 337 CSS px at 8.4 × 17 px cells: 44 columns, 19 rows.
+ expect(computeOwnedSize(372, 337, 8.4, 17)).toEqual({ cols: 44, rows: 19 });
+ // A part cell never counts; an exact fit does despite float error.
+ expect(computeOwnedSize(100, 100, 8, 16)).toEqual({ cols: 20, rows: 6 });
+ expect(computeOwnedSize(336, 340, 8.4, 17)).toEqual({ cols: 40, rows: 20 });
+ });
+
+ it("clamps to sane bounds", () => {
+ expect(computeOwnedSize(50, 30, 8, 16)).toEqual({ cols: OWNED_MIN_COLS, rows: OWNED_MIN_ROWS });
+ expect(computeOwnedSize(100_000, 100_000, 8, 16)).toEqual({
+ cols: OWNED_MAX_COLS,
+ rows: OWNED_MAX_ROWS,
+ });
+ expect([OWNED_MIN_COLS, OWNED_MAX_COLS, OWNED_MIN_ROWS, OWNED_MAX_ROWS]).toEqual([20, 500, 5, 200]);
+ });
+
+ it("is unknown until both the panel and the cell are measured", () => {
+ expect(computeOwnedSize(0, 300, 8, 16)).toBeNull();
+ expect(computeOwnedSize(300, 0, 8, 16)).toBeNull();
+ expect(computeOwnedSize(300, 300, 0, 16)).toBeNull();
+ expect(computeOwnedSize(300, 300, 8, Number.NaN)).toBeNull();
+ });
+});
+
describe("readable floor", () => {
// 14 px JetBrains Mono: 8.4 px cell, 17 px row in a real browser.
const ROW_HEIGHT = 17;
@@ -609,4 +647,127 @@ describe("TerminalView", () => {
expect(follows).toEqual([false]);
view.dispose();
});
+
+ // happy-dom has no layout: a panel of panelW × panelH and a grid whose
+ // natural size follows the handle's cols × rows at 8 × 16 px cells.
+ function layOut(
+ parent: HTMLElement,
+ handle: FakeHandle,
+ panelW: number,
+ panelH: number,
+ ): { fit: HTMLElement; host: HTMLElement } {
+ const fit = parent.querySelector(".term-fit");
+ const host = parent.querySelector(".term-host");
+ if (!fit || !host) {
+ throw new Error("missing scaffold");
+ }
+ Object.defineProperty(fit, "clientWidth", { value: panelW, configurable: true });
+ Object.defineProperty(fit, "clientHeight", { value: panelH, configurable: true });
+ Object.defineProperty(host, "scrollWidth", { get: () => handle.cols * 8, configurable: true });
+ Object.defineProperty(host, "scrollHeight", { get: () => handle.rows * 16, configurable: true });
+ return { fit, host };
+ }
+
+ it("computes the size that fits its panel from the measured cell", () => {
+ const { parent, view, handles } = setup();
+ expect(view.ownedSize()).toBeNull();
+ view.mount(parent, 80, 24);
+ const handle = handles[0]!;
+ layOut(parent, handle, 400, 320);
+ // 400 / 8 and 320 / 16, whatever size the host had.
+ expect(view.ownedSize()).toEqual({ cols: 50, rows: 20 });
+ // The zoom grows the cell, so fewer fit.
+ view.setZoom(2);
+ expect(view.ownedSize()).toEqual({ cols: 25, rows: 10 });
+ // The DPR-1 double-size face shows at its base scale, not its own size.
+ view.setZoom(1);
+ handle.baseScale = 0.5;
+ expect(view.ownedSize()).toEqual({ cols: 100, rows: 40 });
+ view.dispose();
+ });
+
+ it("resizes the local terminal in place and refits", () => {
+ const { parent, view, handles } = setup();
+ view.mount(parent, 80, 24);
+ const handle = handles[0]!;
+ const { host } = layOut(parent, handle, 400, 320);
+ expect(view.resize(50, 20)).toBe(true);
+ expect(handle.resizes).toEqual([{ cols: 50, rows: 20 }]);
+ expect(view.size).toEqual({ cols: 50, rows: 20 });
+ // Fitted to the width as before: 400 / (50 × 8).
+ expect(host.style.transform).toBe("scale(1)");
+ // Unchanged or invalid sizes are no-ops.
+ expect(view.resize(50, 20)).toBe(false);
+ expect(view.resize(0, 20)).toBe(false);
+ expect(view.resize(50.5, 20)).toBe(false);
+ expect(handles).toHaveLength(1);
+ expect(handle.resizes).toHaveLength(1);
+ view.dispose();
+ });
+
+ it("opens a terminal still behind the font gate at the resized grid", async () => {
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ Object.defineProperty(document, "fonts", {
+ configurable: true,
+ value: { load: () => gate, ready: Promise.resolve() },
+ });
+ try {
+ const { parent, view, handles } = setup();
+ view.mount(parent, 80, 24);
+ expect(handles).toHaveLength(0);
+ view.resize(50, 20);
+ release();
+ await view.ready();
+ expect(handles[0]?.cols).toBe(50);
+ expect(handles[0]?.rows).toBe(20);
+ view.dispose();
+ } finally {
+ Reflect.deleteProperty(document, "fonts");
+ }
+ });
+
+ it("renders a size owner at its base scale times the zoom, never width-fitted", () => {
+ const { parent, view, handles } = setup();
+ view.mount(parent, 80, 24);
+ const handle = handles[0]!;
+ const { host } = layOut(parent, handle, 400, 320);
+ view.refit();
+ expect(host.style.transform).toBe(`scale(${400 / 640})`);
+ view.setSizeOwner(true);
+ expect(view.isSizeOwner).toBe(true);
+ expect(parent.dataset.sizeOwner).toBe("true");
+ expect(host.style.transform).toBe("scale(1)");
+ handle.baseScale = 0.5;
+ view.setZoom(1.5);
+ expect(host.style.transform).toBe("scale(0.75)");
+ // Off again: back to following the host's size, fitted to the width.
+ view.setSizeOwner(false);
+ expect(parent.dataset.sizeOwner).toBeUndefined();
+ expect(host.style.transform).toBe(`scale(${(400 / 640) * 1.5})`);
+ view.setSizeOwner(true);
+ view.dispose();
+ expect(parent.dataset.sizeOwner).toBeUndefined();
+ expect(view.isSizeOwner).toBe(false);
+ });
+
+ it("reports every zoom change", () => {
+ const parent = document.createElement("div");
+ document.body.appendChild(parent);
+ let zooms = 0;
+ const view = new TerminalView((_host, cols, rows) => new FakeHandle(cols, rows), {
+ onUserInput: () => {},
+ onZoomChange: () => {
+ zooms += 1;
+ },
+ });
+ view.mount(parent, 80, 24);
+ view.zoomIn();
+ view.zoomOut();
+ view.setZoom(2);
+ expect(zooms).toBe(3);
+ view.dispose();
+ });
});
diff --git a/apps/remote/web/test/touch-targets.spec.ts b/apps/remote/web/test/touch-targets.spec.ts
index d08f58a..eabc72a 100644
--- a/apps/remote/web/test/touch-targets.spec.ts
+++ b/apps/remote/web/test/touch-targets.spec.ts
@@ -117,3 +117,102 @@ test("touch_targets_meet_44px", async ({ page }) => {
expect(control.height, `${control.label} height`).toBeGreaterThanOrEqual(44);
}
});
+
+// Audit (2026-11-04): the terminal toolbar at phone widths. Keys goes
+// icon-only under 640 px (aria-label and title name it), so no label runs
+// past its button; the latest button floats over the terminal card's
+// bottom-right corner instead of wrapping onto a second toolbar row (which
+// cost the terminal a row), so the toolbar stays one 44 px row; and the
+// size switch's label is a 44 px target.
+for (const viewport of [
+ { width: 360, height: 640 },
+ { width: 390, height: 844 },
+] as const) {
+ test(`terminal toolbar fits without clipping at ${viewport.width}px`, async ({ page }, testInfo) => {
+ await page.setViewportSize(viewport);
+ await page.goto("/fake.html");
+ await page.locator('[data-session="smoke"]').click();
+ await expect(page.locator("#term-pane")).toBeVisible();
+ // Reveal latest the way scrolling up does, so the full row is measured.
+ await page.evaluate(() => {
+ const latest = document.querySelector("#term-latest");
+ if (latest) {
+ latest.hidden = false;
+ }
+ });
+ const report = await page.evaluate(() => {
+ const doc = document.scrollingElement ?? document.documentElement;
+ const controls = document.querySelector(".term-controls") as HTMLElement;
+ const buttons = [...controls.querySelectorAll("button")]
+ .filter((el) => el.offsetParent !== null)
+ .map((el) => {
+ const box = el.getBoundingClientRect();
+ return {
+ id: el.id,
+ width: box.width,
+ height: box.height,
+ left: box.left,
+ top: box.top,
+ right: box.right,
+ clipped: el.scrollWidth > el.clientWidth + 1,
+ };
+ });
+ const toggle = document.querySelector(".term-size-toggle")?.getBoundingClientRect();
+ const latestEl = document.querySelector("#term-latest");
+ const latest = latestEl?.getBoundingClientRect();
+ const pane = document.querySelector("#term-pane")?.getBoundingClientRect();
+ const rowBox = controls.getBoundingClientRect();
+ return {
+ pageOverflow: doc.scrollWidth - doc.clientWidth,
+ rowOverflow: controls.scrollWidth - controls.clientWidth,
+ rowHeight: rowBox.height,
+ buttons,
+ toggle: toggle ? { width: toggle.width, height: toggle.height } : null,
+ latestInToolbar: latestEl?.closest(".term-controls") !== null,
+ latestLabel: latestEl?.getAttribute("aria-label") ?? null,
+ latest: latest
+ ? { width: latest.width, height: latest.height, right: latest.right, bottom: latest.bottom, left: latest.left, top: latest.top }
+ : null,
+ pane: pane ? { right: pane.right, bottom: pane.bottom, left: pane.left, top: pane.top } : null,
+ };
+ });
+ process.stdout.write(`toolbar at ${viewport.width}px: ${JSON.stringify(report)}\n`);
+ await page.screenshot({ path: testInfo.outputPath(`toolbar-${viewport.width}.png`) });
+ expect(report.pageOverflow).toBeLessThanOrEqual(1);
+ expect(report.rowOverflow).toBeLessThanOrEqual(1);
+ // One toolbar row: every control at the 44 px floor, none wrapped.
+ expect(report.rowHeight).toBeLessThan(2 * 44);
+ expect(new Set(report.buttons.map((button) => Math.round(button.top))).size).toBe(1);
+ // latest floats over the card's bottom-right corner: a 44 px named
+ // target inside the pane, never in the toolbar.
+ expect(report.latestInToolbar).toBe(false);
+ expect(report.latestLabel).toBe("Scroll to the latest output");
+ const latest = report.latest;
+ const pane = report.pane;
+ expect(latest).not.toBeNull();
+ expect(pane).not.toBeNull();
+ if (latest && pane) {
+ expect(latest.width).toBeGreaterThanOrEqual(44);
+ expect(latest.height).toBeGreaterThanOrEqual(44);
+ expect(latest.right).toBeLessThanOrEqual(pane.right);
+ expect(latest.bottom).toBeLessThanOrEqual(pane.bottom);
+ expect(pane.right - latest.right).toBeLessThanOrEqual(16);
+ expect(pane.bottom - latest.bottom).toBeLessThanOrEqual(16);
+ expect(latest.left).toBeGreaterThanOrEqual(pane.left);
+ expect(latest.top).toBeGreaterThanOrEqual(pane.top);
+ }
+ for (const button of report.buttons) {
+ expect(button.clipped, `${button.id} clips its label`).toBe(false);
+ expect(button.width, `${button.id} width`).toBeGreaterThanOrEqual(44);
+ expect(button.height, `${button.id} height`).toBeGreaterThanOrEqual(44);
+ expect(button.left, `${button.id} left`).toBeGreaterThanOrEqual(0);
+ expect(button.right, `${button.id} right`).toBeLessThanOrEqual(viewport.width);
+ }
+ expect(report.toggle?.width ?? 0).toBeGreaterThanOrEqual(44);
+ expect(report.toggle?.height ?? 0).toBeGreaterThanOrEqual(44);
+ // The switch toggles from the keyboard.
+ await page.locator("#term-control-size").focus();
+ await page.keyboard.press("Space");
+ await expect(page.locator("#term-control-size")).toBeChecked();
+ });
+}
diff --git a/packages/look/banner/banner.css b/packages/look/banner/banner.css
index 3f4cd1a..26026cd 100644
--- a/packages/look/banner/banner.css
+++ b/packages/look/banner/banner.css
@@ -25,7 +25,7 @@
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 0.08em;
- color: #9dcfa1;
+ color: #52fff6;
}
.look-banner-vh {
diff --git a/packages/look/banner/banner.ts b/packages/look/banner/banner.ts
index 2be79de..9ad5c4c 100644
--- a/packages/look/banner/banner.ts
+++ b/packages/look/banner/banner.ts
@@ -1,12 +1,11 @@
-// The art is ported from harness-canopy src/shared/banner.rs (BANNER);
+// Ported from harness-canopy src/shared/banner.rs (BANNER, BANNER_GRADIENT, gradient_rgb)
// SPDX-License-Identifier: Apache-2.0
/**
* The house banner: Canopy in the DOS Rebel figlet font, one colour per
- * line over the landing's eight-stop wizard green gradient, light at the
- * top to dark at the bottom. The ten art lines are byte-identical to the
- * `BANNER` block `banner.rs` prints — trailing spaces included, never
- * retyped — while the gradient follows the Canopy landing page
- * (`univerlab/src/components/Banner.astro`), not the TUI's teal scale.
+ * line over the TUI's five-stop Canopy teal scale, light at the top to
+ * dark at the bottom, ported from `banner.rs` (`BANNER_GRADIENT`). The
+ * ten art lines are byte-identical to the `BANNER` block `banner.rs`
+ * prints — trailing spaces included, never retyped.
* Pair with banner.css; `npm run build:look` copies both into the apps.
*
* The header banners below are the same font rendered once with
@@ -64,35 +63,33 @@ export const REMOTE_BANNER_LINES: readonly string[] = [
];
/**
- * The landing's eight wizard greens, verbatim from
- * `univerlab/src/components/Banner.astro`, light at the top of the banner
- * to dark at the bottom.
+ * The TUI's Canopy teal scale as `banner.rs` paints the banner: the five
+ * legible stops, light at the top to dark at the bottom. `#003330` (the
+ * darkest scale stop) is excluded: it is a background tone only, never
+ * banner ink.
*/
export const BANNER_GRADIENT: readonly string[] = [
- "#9dcfa1",
- "#84be89",
- "#6cae71",
- "#559d5a",
- "#3f8d44",
- "#2b7a30",
- "#1a6620",
- "#0c5212",
+ "#52fff6",
+ "#1ffff3",
+ "#00e0d4",
+ "#00a9a0",
+ "#006661",
];
/**
* The colour of art line `index` in a `count`-line banner: an exact port
- * of the landing's `gradColor` — `round(index / max(1, count - 1) * 7)`
- * over the eight stops, so the first line always takes stop 0 and the
- * last line stop 7. The ten-line canopy banner runs stops
- * [0,1,2,2,3,4,5,5,6,7] and the eleven-line headers
- * [0,1,1,2,3,4,4,5,6,6,7]. The default count is the house banner's ten
+ * of `banner.rs` `gradient_rgb` — `round(index / max(1, count) * 4)`
+ * over the five stops, clamped to the last — so the ten-line canopy
+ * banner runs stops [0,0,1,1,2,2,2,3,3,4] and the eleven-line headers
+ * [0,0,1,1,1,2,2,3,3,3,4]. The default count is the house banner's ten
* lines, spelled as a number rather than `BANNER_LINES.length` so an app
* that never draws the house banner tree-shakes its art out of its
* bundle.
*/
export function bannerColor(index: number, count = 10): string {
- const stop = Math.min(7, Math.round((index / Math.max(1, count - 1)) * 7));
- return BANNER_GRADIENT[stop] ?? "#9dcfa1";
+ const last = BANNER_GRADIENT.length - 1;
+ const stop = Math.min(last, Math.round((index / Math.max(1, count)) * last));
+ return BANNER_GRADIENT[stop] ?? "#52fff6";
}
function escapeHtml(value: string): string {
@@ -143,7 +140,7 @@ export type BannerApp = "remote" | "graphs";
* Render the banner: the ten art lines as an SVG hidden from assistive
* tech, a visually hidden "Canopy" for readers, and the app name in the
* mono face, as the TUI prints its title. banner.css paints the app word
- * (`#9dcfa1`, the lightest wizard stop); no style attribute, so the
+ * (`#52fff6`, the lightest teal stop); no style attribute, so the
* remote's CSP stays happy.
*/
export function renderBanner(app: BannerApp): string {
diff --git a/packages/look/icons.ts b/packages/look/icons.ts
index 8c3a5d5..6ed41d4 100644
--- a/packages/look/icons.ts
+++ b/packages/look/icons.ts
@@ -36,6 +36,7 @@ import {
Folder,
FolderOpen,
Github,
+ Keyboard,
Loader,
LogOut,
Maximize,
@@ -89,6 +90,7 @@ export {
Folder as folder,
FolderOpen as folderOpen,
Github as github,
+ Keyboard as keyboard,
Loader as loader,
LogOut as logOut,
Maximize as maximize,
diff --git a/packages/plate/layout.ts b/packages/plate/layout.ts
index 0ac0f67..697c4d8 100644
--- a/packages/plate/layout.ts
+++ b/packages/plate/layout.ts
@@ -46,7 +46,7 @@ import {
} from "./lane.js";
import { rankUnits } from "./rank.js";
import { generateWalk, happyRandom, plateWalk, type WalkFail } from "./walk.js";
-import { GEOM, edgePath, num, nodeBox, shortenEdgeEnd, smooth, type Pt } from "./vocabulary.js";
+import { GEOM, cubicExtent, edgePath, num, nodeBox, shortenEdgeEnd, smooth, type Pt } from "./vocabulary.js";
import {
GROUP_CHAR,
GROUP_FOOT,
@@ -494,6 +494,32 @@ export function layoutPlate(document: unknown, random: () => number = happyRando
);
const lifts = new Map(orderedRaw.map((edge) => [edge, liftOf(edge)]));
+ // Arrowhead tips land on the target border: node endpoints sit on
+ // centres, so both branches shorten; group endpoints already sit on the
+ // box border, except arcs, whose -3 end dives inside the box. Sizes only,
+ // so the end is the same before and after the shift into the viewBox.
+ const targetOf = new Map();
+ for (const node of nodes) {
+ const rot = GEOM[node.shape].rot !== 0;
+ targetOf.set(node.name, {
+ hx: ((rot ? node.w * Math.SQRT2 : node.w) / 2),
+ hy: ((rot ? node.h * Math.SQRT2 : node.h) / 2),
+ group: false,
+ });
+ }
+ for (const group of groups) {
+ targetOf.set(group.name, { hx: group.w / 2, hy: group.h / 2, group: true });
+ }
+ const drawnEnd = (edge: RawEdge, a: Pt, b: Pt): Pt => {
+ const target = edge.cls === "order" ? undefined : targetOf.get(edge.to);
+ if (target === undefined || (target.group && !arcs(edge))) {
+ return b;
+ }
+ return arcs(edge)
+ ? shortenEdgeEnd(a, b, target.hy, "y", 1, 3)
+ : shortenEdgeEnd(a, b, target.hx, "x", null, 0);
+ };
+
// Bounds: shapes, group outlines, labels, fail-arc tops, marks, buses,
// done terminals and lane routes — the viewBox grows around all of them,
// then everything shifts into positive space.
@@ -544,10 +570,15 @@ export function layoutPlate(document: unknown, random: () => number = happyRando
}
}
}
+ // An arc bounds by the curve it draws — from its start to its shortened
+ // end through the rise — not by its control points, and in the same
+ // design coordinates as everything else here.
for (const edge of raw) {
if (arcs(edge)) {
const rise = arcRise(edge.a, edge.b, lifts.get(edge) ?? 0);
- minY = Math.min(minY, rise, edge.a.y - 3, edge.b.y - 3);
+ const end = drawnEnd(edge, edge.a, edge.b);
+ const reach = cubicExtent(edge.a.y - 3, rise, rise, end.y - 3);
+ span(Math.min(edge.a.x, end.x), Math.max(edge.a.x, end.x), reach.min, reach.max);
}
}
if (!Number.isFinite(minX)) {
@@ -612,33 +643,15 @@ export function layoutPlate(document: unknown, random: () => number = happyRando
laneEdges.push({ from: route.from, to: route.to, cls: route.cls, d: smooth(lanePath(points)) });
}
}
- const targetOf = new Map();
- for (const node of nodes) {
- const rot = GEOM[node.shape].rot !== 0;
- targetOf.set(node.name, {
- hx: ((rot ? node.w * Math.SQRT2 : node.w) / 2),
- hy: ((rot ? node.h * Math.SQRT2 : node.h) / 2),
- group: false,
- });
- }
- for (const group of groups) {
- targetOf.set(group.name, { hx: group.w / 2, hy: group.h / 2, group: true });
- }
+ // The rise is measured in design coordinates, against mainTop; the paths
+ // are drawn after the shift, so the rise shifts with them. Unshifted, it
+ // put every arc's top `dy` above the bounds, outside the viewBox.
+ const riseAt = (edge: RawEdge): number => num(arcRise(edge.a, edge.b, lifts.get(edge) ?? 0) + dy);
const edges: PlacedEdge[] = [
...raw.map((edge) => {
const a = shift(edge.a);
- const b = shift(edge.b);
- // Arrowhead tips land on the target border: node endpoints sit on
- // centres, so both branches shorten; group endpoints already sit on
- // the box border, except arcs, whose -3 end dives inside the box.
- const target = edge.cls === "order" ? undefined : targetOf.get(edge.to);
+ const end = drawnEnd(edge, a, shift(edge.b));
const arc = arcs(edge);
- let end = b;
- if (target !== undefined && (!target.group || arc)) {
- end = arc
- ? shortenEdgeEnd(a, b, target.hy, "y", 1, 3)
- : shortenEdgeEnd(a, b, target.hx, "x", null, 0);
- }
return {
from: edge.from,
to: edge.to,
@@ -650,7 +663,7 @@ export function layoutPlate(document: unknown, random: () => number = happyRando
edge.cls === "fail",
lifts.get(edge) ?? 0,
arc,
- arc ? arcRise(edge.a, edge.b, lifts.get(edge) ?? 0) : undefined,
+ arc ? riseAt(edge) : undefined,
),
),
};
@@ -680,7 +693,7 @@ export function layoutPlate(document: unknown, random: () => number = happyRando
failStyle.set(key, {
lift,
forceArc: edge.forceArc,
- rise: arcs(edge) ? arcRise(edge.a, edge.b, lift) : undefined,
+ rise: arcs(edge) ? riseAt(edge) : undefined,
});
}
}
diff --git a/packages/plate/vocabulary.ts b/packages/plate/vocabulary.ts
index 048327a..d2f5cce 100644
--- a/packages/plate/vocabulary.ts
+++ b/packages/plate/vocabulary.ts
@@ -118,6 +118,46 @@ export function edgePath(a: Pt, b: Pt, fail: boolean, lift = 0, forceArc = false
return `M${a.x} ${a.y - 3} C${a.x} ${top} ${b.x} ${top} ${b.x} ${b.y - 3}`;
}
+/**
+ * The extent one axis of a cubic Bézier actually reaches, from its four
+ * coordinates on that axis: the endpoints and any interior turning point.
+ * A curve stays inside its control hull but rarely touches it, so bounds
+ * read this instead of the control points — an arc's apex sits a quarter
+ * of its height below its controls.
+ */
+export function cubicExtent(p0: number, p1: number, p2: number, p3: number): { min: number; max: number } {
+ let min = Math.min(p0, p3);
+ let max = Math.max(p0, p3);
+ const at = (t: number): number => {
+ const u = 1 - t;
+ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
+ };
+ const take = (t: number): void => {
+ if (t > 0 && t < 1) {
+ const value = at(t);
+ min = Math.min(min, value);
+ max = Math.max(max, value);
+ }
+ };
+ // The derivative over 3: a t² + b t + c.
+ const a = -p0 + 3 * p1 - 3 * p2 + p3;
+ const b = 2 * (p0 - 2 * p1 + p2);
+ const c = p1 - p0;
+ if (Math.abs(a) < 1e-12) {
+ if (Math.abs(b) > 1e-12) {
+ take(-c / b);
+ }
+ return { min, max };
+ }
+ const disc = b * b - 4 * a * c;
+ if (disc >= 0) {
+ const root = Math.sqrt(disc);
+ take((-b + root) / (2 * a));
+ take((-b - root) / (2 * a));
+ }
+ return { min, max };
+}
+
/** A node's drawn half extents: a rotated gate's bounding box is the wider one. */
export function nodeBox(node: { shape: Kind; w: number; h: number }): { hw: number; hh: number } {
const rot = GEOM[node.shape].rot !== 0;
diff --git a/tests/look.test.ts b/tests/look.test.ts
index d039e5a..0f9f1bb 100644
--- a/tests/look.test.ts
+++ b/tests/look.test.ts
@@ -184,7 +184,7 @@ describe("bannerSvg", () => {
const fills = [...svg.matchAll(//g)].map(
(match) => match[1],
);
- const stops = [0, 1, 1, 2, 3, 4, 4, 5, 6, 6, 7];
+ const stops = [0, 0, 1, 1, 1, 2, 2, 3, 3, 3, 4];
expect(fills).toEqual(stops.map((stop) => BANNER_GRADIENT[stop]));
// A style attribute is refused by the remote's CSP: fills never use one.
expect(svg).not.toContain("style=");
@@ -208,21 +208,13 @@ describe("the house banner", () => {
expect(BANNER_LINES[1]).toContain("██████");
});
- it("runs the landing's eight wizard greens light to dark", () => {
- expect(BANNER_GRADIENT).toEqual([
- "#9dcfa1",
- "#84be89",
- "#6cae71",
- "#559d5a",
- "#3f8d44",
- "#2b7a30",
- "#1a6620",
- "#0c5212",
- ]);
+ it("runs the TUI's five-stop Canopy teal scale light to dark", () => {
+ expect(BANNER_GRADIENT).toEqual(["#52fff6", "#1ffff3", "#00e0d4", "#00a9a0", "#006661"]);
+ expect(BANNER_GRADIENT).not.toContain("#003330");
});
- it("colours the ten lines stops [0,1,2,2,3,4,5,5,6,7], first lightest and last darkest", () => {
- const stops = [0, 1, 2, 2, 3, 4, 5, 5, 6, 7];
+ it("colours the ten lines stops [0,0,1,1,2,2,2,3,3,4], like gradient_rgb", () => {
+ const stops = [0, 0, 1, 1, 2, 2, 2, 3, 3, 4];
expect(BANNER_LINES.map((_, index) => bannerColor(index))).toEqual(
stops.map((stop) => BANNER_GRADIENT[stop]),
);
@@ -262,8 +254,8 @@ describe("the header banners", () => {
expect(REMOTE_BANNER_LINES[2]).toContain("████████");
});
- it("colours the eleven lines stops [0,1,1,2,3,4,4,5,6,6,7], first lightest and last darkest", () => {
- const stops = [0, 1, 1, 2, 3, 4, 4, 5, 6, 6, 7];
+ it("colours the eleven lines stops [0,0,1,1,1,2,2,3,3,3,4], like gradient_rgb", () => {
+ const stops = [0, 0, 1, 1, 1, 2, 2, 3, 3, 3, 4];
for (const lines of [GRAPHS_BANNER_LINES, REMOTE_BANNER_LINES]) {
expect(lines.map((_, index) => bannerColor(index, lines.length))).toEqual(
stops.map((stop) => BANNER_GRADIENT[stop]),