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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/257-take-the-parent-s-phone-fix-the-page-nev → main 2 commits fork
8 files
changed +320
−31
src/web/layout.ts +25 −13
| @@ -135,11 +135,14 @@ function modeToggle(ctx: Ctx): Html { | ||
| 135 | 135 | <button |
| 136 | 136 | type="submit" |
| 137 | 137 | aria-pressed="${ctx.mode === mode ? "true" : "false"}" |
| 138 | aria-label="${label}" | |
| 138 | 139 | title="${mode === "studio" |
| 139 | 140 | ? "Apps, the crew, what asks you" |
| 140 | 141 | : "Code, commits, branches, runs"}" |
| 141 | 142 | > |
| 142 | ${label} | |
| 143 | ${icon(mode === "studio" ? "rocket" : "code")}<span class="hide-sm" | |
| 144 | >${label}</span | |
| 145 | > | |
| 143 | 146 | </button> |
| 144 | 147 | </form>`; |
| 145 | 148 | return html`<span class="mode-toggle" role="group" aria-label="Studio or dev"> |
| @@ -197,6 +200,11 @@ function userMenu(user: User, signOut: MenuItem): Html { | ||
| 197 | 200 | { label: "Apps", href: "/apps", icon: "rocket" }, |
| 198 | 201 | { label: "Map", href: "/map", icon: "map" }, |
| 199 | 202 | { 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: "" }, | |
| 200 | 208 | { label: "Settings", href: "/settings", icon: "settings" }, |
| 201 | 209 | { label: "Access tokens", href: "/settings/tokens", icon: "key" }, |
| 202 | 210 | { label: "SSH keys", href: "/settings/keys", icon: "lock" }, |
| @@ -243,8 +251,10 @@ function searchBar(): Html { | ||
| 243 | 251 | * the shell script rather than shown and then found to be dead. |
| 244 | 252 | */ |
| 245 | 253 | 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. | |
| 246 | 256 | return html`<button |
| 247 | class="btn btn-icon js-only" | |
| 257 | class="btn btn-icon js-only hide-sm" | |
| 248 | 258 | type="button" |
| 249 | 259 | data-help |
| 250 | 260 | aria-label="Keyboard shortcuts" |
| @@ -430,18 +440,20 @@ function topbar( | ||
| 430 | 440 | )} |
| 431 | 441 | <span class="spacer"></span> |
| 432 | 442 | <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)} | |
| 434 | 446 | ${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 | )}` | |
| 445 | 457 | : btn("Sign in", { |
| 446 | 458 | href: |
| 447 | 459 | ctx.app.config.edgeToken === undefined |
src/web/scope.ts −13
| @@ -470,19 +470,6 @@ export function whereRow(ctx: Ctx, scope: RepoScope): Html { | ||
| 470 | 470 | class="where-pin" |
| 471 | 471 | > |
| 472 | 472 | <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> | |
| 486 | 473 | </form>`, |
| 487 | 474 | )} |
| 488 | 475 | </div>`; |
src/web/style/base.ts +11 −1
| @@ -94,6 +94,7 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; } | ||
| 94 | 94 | } |
| 95 | 95 | .wordmark { |
| 96 | 96 | display: inline-flex; align-items: center; gap: 8px; flex: none; |
| 97 | min-height: 44px; | |
| 97 | 98 | font-weight: 660; letter-spacing: -0.03em; font-size: 15px; |
| 98 | 99 | } |
| 99 | 100 | .wordmark .mark { |
| @@ -106,7 +107,16 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; } | ||
| 106 | 107 | .topbar-asks { display: inline-flex; flex: none; text-decoration: none; } |
| 107 | 108 | .topbar-asks:hover .pill { background: color-mix(in srgb, var(--accent) 24%, transparent); } |
| 108 | 109 | .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 | } | |
| 110 | 120 | |
| 111 | 121 | .foot { |
| 112 | 122 | 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); } | ||
| 593 | 593 | font-size: 14px; overflow: hidden; |
| 594 | 594 | } |
| 595 | 595 | .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; } } | |
| 596 | 599 | .crumbs .sep { color: var(--faint); flex: none; } |
| 597 | 600 | .crumbs [aria-current="page"] { font-weight: 550; } |
| 598 | 601 | .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); } | ||
| 708 | 711 | /* Studio or dev: two buttons in the topbar, the chosen one filled. */ |
| 709 | 712 | .mode-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; height: 28px; align-self: center; } |
| 710 | 713 | .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; } } | |
| 712 | 718 | .mode-toggle button[aria-pressed="true"] { background: var(--fg); color: var(--bg); } |
| 713 | 719 | .mode-toggle button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } |
| 714 | 720 | `; |
src/web/style/content.ts +2
| @@ -57,6 +57,8 @@ a.where-place:hover, button.where-place:hover { | ||
| 57 | 57 | border-color: var(--border); background: var(--muted); color: var(--fg); |
| 58 | 58 | } |
| 59 | 59 | .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; } } | |
| 60 | 62 | .where-dot { font-size: 10px; line-height: 1; } |
| 61 | 63 | .where-note { color: var(--faint); } |
| 62 | 64 | /* 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", () => { | ||
| 498 | 498 | expect(strip).toContain("◆"); |
| 499 | 499 | expect(strip).toContain("this machine"); |
| 500 | 500 | 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"); | |
| 504 | 503 | }); |
| 505 | 504 | |
| 506 | 505 | 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 | ); |
new above
offline