Public: anyone can read this; the crew can change it.

Take the parent's phone fix: the page never scrolls sideways (first half of #250) #268

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/257-take-the-parent-s-phone-fix-the-page-nev → main 2 commits fork

  • Merged. scout/257-take-the-parent-s-phone-fix-the-page-nev was merged into main as 266b9c0.

Review

Sign in to review this change.
8 files changed +320 −31
src/web/layout.ts +25 −13
@@ -135,11 +135,14 @@ function modeToggle(ctx: Ctx): Html {
135135 <button
136136 type="submit"
137137 aria-pressed="${ctx.mode === mode ? "true" : "false"}"
138 aria-label="${label}"
138139 title="${mode === "studio"
139140 ? "Apps, the crew, what asks you"
140141 : "Code, commits, branches, runs"}"
141142 >
142 ${label}
143 ${icon(mode === "studio" ? "rocket" : "code")}<span class="hide-sm"
144 >${label}</span
145 >
143146 </button>
144147 </form>`;
145148 return html`<span class="mode-toggle" role="group" aria-label="Studio or dev">
@@ -197,6 +200,11 @@ function userMenu(user: User, signOut: MenuItem): Html {
197200 { label: "Apps", href: "/apps", icon: "rocket" },
198201 { label: "Map", href: "/map", icon: "map" },
199202 { separator: true, label: "" },
203 // The "+" menu is hidden on a phone to keep the topbar inside the
204 // screen; creating things stays one tap away here.
205 { label: "New app", href: "/apps/new", icon: "rocket" },
206 { label: "New repository", href: "/new", icon: "repo" },
207 { separator: true, label: "" },
200208 { label: "Settings", href: "/settings", icon: "settings" },
201209 { label: "Access tokens", href: "/settings/tokens", icon: "key" },
202210 { label: "SSH keys", href: "/settings/keys", icon: "lock" },
@@ -243,8 +251,10 @@ function searchBar(): Html {
243251 * the shell script rather than shown and then found to be dead.
244252 */
245253 function helpButton(): Html {
254 // Keyboard shortcuts have no meaning on a phone; the button would only
255 // cost the width that pushes the page sideways.
246256 return html`<button
247 class="btn btn-icon js-only"
257 class="btn btn-icon js-only hide-sm"
248258 type="button"
249259 data-help
250260 aria-label="Keyboard shortcuts"
@@ -430,18 +440,20 @@ function topbar(
430440 )}
431441 <span class="spacer"></span>
432442 <span class="topbar-tools">
433 ${searchBar()}${helpButton()}${modeToggle(ctx)}${themeMenu(theme)}
443 ${searchBar()}<span class="hide-sm">${helpButton()}</span>${modeToggle(
444 ctx,
445 )}${themeMenu(theme)}
434446 ${user
435 ? html`${newMenu()}${userMenu(
436 user,
437 ctx.auth.via === "edge"
438 ? {
439 label: "Sign out",
440 href: edgeLogoutUrl("/"),
441 icon: "signOut",
442 }
443 : { label: "Sign out", post: "/logout", icon: "signOut" },
444 )}`
447 ? html`<span class="hide-sm">${newMenu()}</span>${userMenu(
448 user,
449 ctx.auth.via === "edge"
450 ? {
451 label: "Sign out",
452 href: edgeLogoutUrl("/"),
453 icon: "signOut",
454 }
455 : { label: "Sign out", post: "/logout", icon: "signOut" },
456 )}`
445457 : btn("Sign in", {
446458 href:
447459 ctx.app.config.edgeToken === undefined
src/web/scope.ts −13
@@ -470,19 +470,6 @@ export function whereRow(ctx: Ctx, scope: RepoScope): Html {
470470 class="where-pin"
471471 >
472472 <input type="hidden" name="pinned" value="${pinned ? "0" : "1"}" />
473 <button
474 class="btn"
475 data-size="sm"
476 data-variant="${pinned ? "secondary" : "outline"}"
477 type="submit"
478 title="${pinned
479 ? "Stop keeping this repository on my machine"
480 : "The local client will clone and keep this fresh"}"
481 >
482 ${icon(pinned ? "pin" : "pin", 16)}${pinned
483 ? html`On my machine <span aria-hidden="true">◆</span>`
484 : html`Keep on my machine`}
485 </button>
486473 </form>`,
487474 )}
488475 </div>`;
src/web/style/base.ts +11 −1
@@ -94,6 +94,7 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; }
9494 }
9595 .wordmark {
9696 display: inline-flex; align-items: center; gap: 8px; flex: none;
97 min-height: 44px;
9798 font-weight: 660; letter-spacing: -0.03em; font-size: 15px;
9899 }
99100 .wordmark .mark {
@@ -106,7 +107,16 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; }
106107 .topbar-asks { display: inline-flex; flex: none; text-decoration: none; }
107108 .topbar-asks:hover .pill { background: color-mix(in srgb, var(--accent) 24%, transparent); }
108109 .topbar .spacer { flex: 1; }
109 .topbar-tools { display: flex; align-items: center; gap: 6px; flex: none; }
110 .topbar-tools { display: flex; align-items: center; gap: 6px; flex: 0 1 auto; min-width: 0; }
111 /* The page never scrolls sideways. A strip that must — the tabs, a code
112 table — scrolls inside its own box; nothing else may push the page wide.
113 clip, not hidden: hidden would make the body a scroll container and
114 unstick the topbar. */
115 body { overflow-x: clip; }
116 @media (max-width: 719px) {
117 .topbar { gap: 6px; padding-left: 12px; padding-right: 12px; }
118 .topbar-tools { gap: 4px; }
119 }
110120
111121 .foot {
112122 width: 100%; max-width: var(--page); margin: 0 auto;
src/web/style/components.ts +7 −1
@@ -593,6 +593,9 @@ a.row:hover .row-title { color: var(--accent); }
593593 font-size: 14px; overflow: hidden;
594594 }
595595 .crumbs a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
596 /* On a phone the crumbs would be squeezed to "y… / c."; the page header
597 right below says where you are, in full. */
598 @media (max-width: 719px) { .topbar .crumbs { display: none; } }
596599 .crumbs .sep { color: var(--faint); flex: none; }
597600 .crumbs [aria-current="page"] { font-weight: 550; }
598601 .crumbs.mono, .crumbs.mono a { font-family: var(--mono); font-size: 12.5px; }
@@ -708,7 +711,10 @@ a.row:hover .row-title { color: var(--accent); }
708711 /* Studio or dev: two buttons in the topbar, the chosen one filled. */
709712 .mode-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; height: 28px; align-self: center; }
710713 .mode-toggle form { display: contents; }
711 .mode-toggle button { font: inherit; font-size: 12px; padding: 0 10px; border: 0; background: transparent; color: var(--fg); cursor: pointer; line-height: 26px; }
714 .mode-toggle button { font: inherit; font-size: 12px; padding: 0 10px; border: 0; background: transparent; color: var(--fg); cursor: pointer; line-height: 26px; display: inline-flex; align-items: center; gap: 5px; }
715 .mode-toggle button .ico { flex: none; }
716 @media (max-width: 719px) { .mode-toggle button { padding: 0 9px; } }
717 @media (pointer: coarse) { .mode-toggle { height: 36px; } .mode-toggle button { line-height: 34px; } }
712718 .mode-toggle button[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
713719 .mode-toggle button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
714720 `;
src/web/style/content.ts +2
@@ -57,6 +57,8 @@ a.where-place:hover, button.where-place:hover {
5757 border-color: var(--border); background: var(--muted); color: var(--fg);
5858 }
5959 .where-place .ico { color: var(--faint); flex: none; }
60 /* A thumb needs more than a 12px word to land on. */
61 @media (pointer: coarse) { .where-place { min-height: 32px; padding: 4px 8px; } }
6062 .where-dot { font-size: 10px; line-height: 1; }
6163 .where-note { color: var(--faint); }
6264 /* Accent is interaction everywhere else in the product; here it is the one
test/helpers/chrome.ts added +199
@@ -0,0 +1,199 @@
1 /**
2 * Measuring a page's width in a real, headless Chromium — the only way to
3 * hold "the page never scrolls sideways" here rather than trust it by
4 * reading the CSS. A minimal CDP client over the WebSocket the runtime
5 * already gives us: one browser, its one open page, three domains (Page,
6 * Network, Emulation), no dependency beyond `findChromium`
7 * (`src/looks/capture.ts`), which already knows where the binary is on this
8 * machine.
9 */
10
11 import { mkdtempSync, rmSync } from "node:fs";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { childEnv } from "../../src/apps/process.ts";
15
16 export interface PageWidth {
17 /** `document.documentElement.scrollWidth` once the page has loaded. */
18 scrollWidth: number;
19 viewportWidth: number;
20 /** Up to a handful of elements whose right edge sits past the viewport. */
21 overflowing: string[];
22 }
23
24 interface CdpMessage {
25 id?: number;
26 method?: string;
27 result?: unknown;
28 error?: { message: string };
29 }
30
31 /** A CDP session over one page's own devtools socket. */
32 class Cdp {
33 private nextId = 1;
34 private readonly pending = new Map<number, (msg: CdpMessage) => void>();
35 private readonly waiters = new Map<string, () => void>();
36
37 constructor(private readonly ws: WebSocket) {
38 ws.addEventListener("message", (event) => {
39 const msg = JSON.parse(event.data as string) as CdpMessage & {
40 method?: string;
41 };
42 if (msg.id !== undefined) {
43 this.pending.get(msg.id)?.(msg);
44 this.pending.delete(msg.id);
45 } else if (msg.method !== undefined) {
46 this.waiters.get(msg.method)?.();
47 this.waiters.delete(msg.method);
48 }
49 });
50 }
51
52 send(method: string, params: object = {}): Promise<any> {
53 const id = this.nextId++;
54 return new Promise((resolve, reject) => {
55 this.pending.set(id, (msg) => {
56 if (msg.error !== undefined) reject(new Error(msg.error.message));
57 else resolve(msg.result);
58 });
59 this.ws.send(JSON.stringify({ id, method, params }));
60 });
61 }
62
63 /** Resolves the next time `method` fires as an event — a one-shot wait. */
64 once(method: string): Promise<void> {
65 return new Promise((resolve) => this.waiters.set(method, resolve));
66 }
67 }
68
69 /** The devtools http endpoint's own list of open pages. */
70 async function findPageTarget(
71 debuggerWsUrl: string,
72 ): Promise<{ webSocketDebuggerUrl: string }> {
73 const httpBase = debuggerWsUrl
74 .replace(/^ws:/, "http:")
75 .replace(/\/devtools\/browser\/.*$/, "");
76 const list = (await (await fetch(`${httpBase}/json/list`)).json()) as {
77 type: string;
78 webSocketDebuggerUrl: string;
79 }[];
80 const page = list.find((t) => t.type === "page");
81 if (page === undefined) throw new Error("chromium opened no page to drive");
82 return page;
83 }
84
85 /** Chromium prints its devtools address to stderr once it is ready to drive. */
86 async function readDebuggerUrl(
87 stderr: ReadableStream<Uint8Array>,
88 ): Promise<string> {
89 const reader = stderr.getReader();
90 const decoder = new TextDecoder();
91 let buf = "";
92 try {
93 for (;;) {
94 const { done, value } = await reader.read();
95 if (done) throw new Error("chromium exited before printing its devtools address");
96 buf += decoder.decode(value, { stream: true });
97 const match = buf.match(/DevTools listening on (ws:\/\/\S+)/);
98 if (match) return match[1]!;
99 }
100 } finally {
101 reader.cancel().catch(() => {});
102 }
103 }
104
105 /**
106 * Load `url` in headless Chromium at a `width`×`height` viewport, carrying
107 * one cookie (a session), and report how wide the document ended up.
108 */
109 export async function measurePageWidth(
110 binary: string,
111 url: string,
112 viewport: { width: number; height: number },
113 cookie: { name: string; value: string },
114 ): Promise<PageWidth> {
115 const profile = mkdtempSync(join(tmpdir(), "forge-phone-"));
116 const proc = Bun.spawn(
117 [
118 binary,
119 "--headless=new",
120 "--no-sandbox",
121 "--disable-gpu",
122 "--disable-dev-shm-usage",
123 "--disable-background-networking",
124 "--disable-crash-reporter",
125 "--no-first-run",
126 `--user-data-dir=${profile}`,
127 "--remote-debugging-port=0",
128 ],
129 { env: childEnv({}), stdin: "ignore", stdout: "ignore", stderr: "pipe" },
130 );
131 let ws: WebSocket | undefined;
132 try {
133 const browserWsUrl = await Promise.race([
134 readDebuggerUrl(proc.stderr),
135 Bun.sleep(10_000).then((): string => {
136 throw new Error("chromium never printed its devtools address");
137 }),
138 ]);
139 const page = await findPageTarget(browserWsUrl);
140 ws = new WebSocket(page.webSocketDebuggerUrl);
141 await new Promise<void>((resolve, reject) => {
142 ws!.addEventListener("open", () => resolve());
143 ws!.addEventListener("error", () =>
144 reject(new Error("chromium's devtools socket refused the connection")),
145 );
146 });
147 const cdp = new Cdp(ws);
148 await cdp.send("Page.enable");
149 await cdp.send("Network.enable");
150 // The viewport a real phone gives a page: window-size flags at launch
151 // are not honoured without a display, so this is what actually sets it.
152 await cdp.send("Emulation.setDeviceMetricsOverride", {
153 width: viewport.width,
154 height: viewport.height,
155 screenWidth: viewport.width,
156 screenHeight: viewport.height,
157 deviceScaleFactor: 1,
158 mobile: true,
159 });
160 await cdp.send("Network.setCookie", {
161 name: cookie.name,
162 value: cookie.value,
163 url,
164 });
165 const loaded = cdp.once("Page.loadEventFired");
166 await cdp.send("Page.navigate", { url });
167 await loaded;
168 const { result } = await cdp.send("Runtime.evaluate", {
169 expression: `(() => {
170 // documentElement.clientWidth, not window.innerWidth: after an
171 // in-process navigation under an active device-metrics override,
172 // headless Chromium leaves window.innerWidth stale while every
173 // other viewport-derived value (this one included) is current.
174 const vw = document.documentElement.clientWidth;
175 const overflowing = [...document.querySelectorAll("*")]
176 .filter((el) => el.getBoundingClientRect().right > vw + 1)
177 .slice(0, 8)
178 .map((el) =>
179 el.tagName.toLowerCase() +
180 (el.className && typeof el.className === "string"
181 ? "." + el.className.trim().split(/\\s+/).join(".")
182 : ""),
183 );
184 return JSON.stringify({
185 scrollWidth: document.documentElement.scrollWidth,
186 viewportWidth: vw,
187 overflowing,
188 });
189 })()`,
190 returnByValue: true,
191 });
192 return JSON.parse(result.value as string) as PageWidth;
193 } finally {
194 ws?.close();
195 proc.kill("SIGKILL");
196 await proc.exited.catch(() => {});
197 rmSync(profile, { recursive: true, force: true });
198 }
199 }
test/web-browse.test.ts +2 −3
@@ -498,9 +498,8 @@ describe("browsing an origin repository on the web", () => {
498498 expect(strip).toContain("◆");
499499 expect(strip).toContain("this machine");
500500 expect(strip).not.toContain('data-state="absent"');
501 // The header's own toggle now offers the way back out.
502 const html = await bodyOf(alice, "/alice/widgets");
503 expect(html).toContain("On my machine");
501 // The third place is the toggle, and now offers the way back out.
502 expect(strip).toContain("Stop keeping this repository on my machine");
504503 });
505504
506505 test("starting a mirror that is already running joins it", async () => {
test/web-phone.test.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * The page never scrolls sideways, on a phone.
3 *
4 * The topbar's tools are the width budget: none of them may push the page
5 * wider than the viewport at 360px. `test/web-ui.test.ts` and friends check
6 * the markup and the CSS text; this drives a real, headless Chromium so the
7 * property is held here rather than trusted from the source.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { findChromium } from "../src/looks/capture.ts";
17 import { setLogLevel } from "../src/kernel/log.ts";
18 import { measurePageWidth } from "./helpers/chrome.ts";
19
20 setLogLevel("error");
21
22 const chromium = findChromium();
23 if (chromium === undefined) {
24 console.log(
25 "SKIPPING: no chromium on this machine, so the phone-width test cannot run",
26 );
27 }
28
29 describe.skipIf(chromium === undefined)(
30 "the page never scrolls sideways, on a phone",
31 () => {
32 let dataDir: string;
33 let app: App;
34 let servers: Servers;
35 let base: string;
36 let cookie = { name: "", value: "" };
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-phone-"));
40 app = createApp({ dataDir, port: 0 });
41 servers = await serve(app);
42 base = servers.url;
43 const signup = await fetch(`${base}/signup`, {
44 method: "POST",
45 body: new URLSearchParams({
46 username: "ada",
47 email: "[email protected]",
48 password: "a-long-web-passphrase",
49 }),
50 redirect: "manual",
51 });
52 const set = (signup.headers.get("set-cookie") ?? "").split(";")[0]!;
53 const [name, value] = set.split("=");
54 cookie = { name: name!, value: value! };
55 }, 20_000);
56
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 test("the studio's app list fits a 360px viewport: no element wider than it, no horizontal scroll", async () => {
64 const page = await measurePageWidth(
65 chromium!,
66 `${base}/apps`,
67 { width: 360, height: 800 },
68 cookie,
69 );
70 expect(page.overflowing).toEqual([]);
71 expect(page.scrollWidth).toBeLessThanOrEqual(page.viewportWidth);
72 }, 20_000);
73 },
74 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs