Public: anyone can read this; the crew can change it.
The forge pill can be moved out of the way: drag it to any corner, it stays there per app, and it never covers the app's own controls #3448
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3439-the-forge-pill-can-be-moved-out-of-the-w → main 3 commits fork 1 thread · 1 unresolved
5 files
changed +247
−9
README.md +7
| @@ -911,6 +911,13 @@ environment. Browsers resolve `*.localhost` to loopback, so a laptop forge | ||
| 911 | 911 | needs no DNS at all; elsewhere point a wildcard at it and set |
| 912 | 912 | `FORGE_APP_DOMAIN`. |
| 913 | 913 | |
| 914 | **Move the pill.** If the pill covers something of the app's, drag it (mouse or | |
| 915 | touch) and it snaps to the nearest corner on release; with the pill focused, the | |
| 916 | arrow keys walk it from corner to corner. The corner is kept in the browser's | |
| 917 | `localStorage` for that app's host (bottom-right until then), and the panel opens | |
| 918 | toward the inside of the screen, bounded to it at phone width. The script stays | |
| 919 | under a 10 KB budget (a test holds it), since it loads on every app page. | |
| 920 | ||
| 914 | 921 | The forge pill on an app's pages says where you are: its accessible name reads |
| 915 | 922 | `notes, production` or `notes, preview of pull #7`, and pressing it opens a |
| 916 | 923 | panel with the app, the environment, and a link home. The pull's title is shown |
src/apps/toolbar.ts +75 −7
| @@ -4,8 +4,9 @@ | ||
| 4 | 4 | * immutable, so the page needs no inline script and an app's CSP that |
| 5 | 5 | * allows no foreign scripts simply leaves the pill out. |
| 6 | 6 | * |
| 7 | * The pill lives in a shadow root, sits in the bottom-right corner, and is | |
| 8 | * collapsed to a dot until pressed. It styles itself through the CSSOM | |
| 7 | * The pill lives in a shadow root and is collapsed to a dot until pressed. | |
| 8 | * It sits bottom-right until dragged or arrow-keyed to another corner; the | |
| 9 | * corner is kept per app host in localStorage. It styles itself through the CSSOM | |
| 9 | 10 | * (never a `style` attribute or a `<style>` element), which a strict |
| 10 | 11 | * `style-src` does not block. |
| 11 | 12 | */ |
| @@ -22,10 +23,10 @@ export const toolbarJs = `(function () { | ||
| 22 | 23 | try { home = new URL(me.src).origin + "/"; } catch (e) {} |
| 23 | 24 | var host = document.createElement("div"); |
| 24 | 25 | host.id = "forge-toolbar"; |
| 25 | host.style.cssText = "all:initial;position:fixed;right:12px;bottom:12px;z-index:2147483647"; | |
| 26 | host.style.cssText = "all:initial;position:fixed;z-index:2147483647"; | |
| 26 | 27 | var root = host.attachShadow({ mode: "closed" }); |
| 27 | 28 | var box = document.createElement("div"); |
| 28 | box.style.cssText = "font:13px/1.3 system-ui,sans-serif;color:#fff;display:flex;align-items:center;gap:8px;flex-direction:row-reverse"; | |
| 29 | box.style.cssText = "font:13px/1.3 system-ui,sans-serif;color:#fff;display:flex;align-items:flex-end;gap:8px;flex-direction:row-reverse"; | |
| 29 | 30 | var pill = document.createElement("button"); |
| 30 | 31 | pill.type = "button"; |
| 31 | 32 | var d = me.dataset || {}; |
| @@ -33,13 +34,13 @@ export const toolbarJs = `(function () { | ||
| 33 | 34 | pill.setAttribute("aria-label", [d.app, where].filter(Boolean).join(", ") || "Forge"); |
| 34 | 35 | pill.setAttribute("aria-expanded", "false"); |
| 35 | 36 | pill.textContent = "\\u2692"; |
| 36 | pill.style.cssText = "all:unset;box-sizing:border-box;cursor:pointer;width:44px;height:44px;border-radius:22px;background:#1f2328;color:#fff;text-align:center;line-height:44px;font-size:20px;opacity:.85;box-shadow:0 1px 4px rgba(0,0,0,.4)"; | |
| 37 | pill.style.cssText = "all:unset;box-sizing:border-box;cursor:pointer;width:44px;height:44px;border-radius:22px;background:#1f2328;color:#fff;text-align:center;line-height:44px;font-size:20px;opacity:.85;box-shadow:0 1px 4px rgba(0,0,0,.4);touch-action:none"; | |
| 37 | 38 | var panel = document.createElement("div"); |
| 38 | 39 | panel.hidden = true; |
| 39 | 40 | panel.setAttribute("role", "region"); |
| 40 | 41 | panel.setAttribute("aria-label", "Where you are"); |
| 41 | 42 | panel.tabIndex = -1; |
| 42 | panel.style.cssText = "outline:none;background:#1f2328;color:#fff;padding:8px 12px;border-radius:14px;box-shadow:0 1px 4px rgba(0,0,0,.4);max-width:min(280px,calc(100vw - 64px));flex-direction:column;gap:2px;display:none"; | |
| 43 | panel.style.cssText = "outline:none;background:#1f2328;color:#fff;padding:8px 12px;border-radius:14px;box-shadow:0 1px 4px rgba(0,0,0,.4);max-width:min(280px,calc(100vw - 64px));max-height:calc(100vh - 88px);overflow:auto;flex-direction:column;gap:2px;display:none"; | |
| 43 | 44 | function line(text, bold) { |
| 44 | 45 | var el = document.createElement("div"); |
| 45 | 46 | el.textContent = text; |
| @@ -118,6 +119,70 @@ export const toolbarJs = `(function () { | ||
| 118 | 119 | }) |
| 119 | 120 | .catch(function () {}); |
| 120 | 121 | } catch (e) {} |
| 122 | // Where the pill sits: one of four corners, kept per app host (localStorage | |
| 123 | // is per origin), so a page's own controls can be left uncovered. | |
| 124 | var KEY = "forge-pill-corner"; | |
| 125 | var CORNERS = ["tl", "tr", "bl", "br"]; | |
| 126 | var corner = "br"; | |
| 127 | function store(k, v) { try { window.localStorage.setItem(k, v); } catch (e) {} } | |
| 128 | try { | |
| 129 | var saved = window.localStorage.getItem(KEY); | |
| 130 | if (CORNERS.indexOf(saved) >= 0) corner = saved; | |
| 131 | } catch (e) {} | |
| 132 | function place(c) { | |
| 133 | corner = c; | |
| 134 | var top = c.charAt(0) === "t", left = c.charAt(1) === "l"; | |
| 135 | host.style.top = top ? "12px" : "auto"; | |
| 136 | host.style.bottom = top ? "auto" : "12px"; | |
| 137 | host.style.left = left ? "12px" : "auto"; | |
| 138 | host.style.right = left ? "auto" : "12px"; | |
| 139 | // The panel opens toward the inside of the screen. | |
| 140 | box.style.flexDirection = left ? "row" : "row-reverse"; | |
| 141 | box.style.alignItems = top ? "flex-start" : "flex-end"; | |
| 142 | } | |
| 143 | place(corner); | |
| 144 | var drag = null, moved = false, until = 0; | |
| 145 | pill.addEventListener("pointerdown", function (e) { | |
| 146 | drag = { x: e.clientX, y: e.clientY, dx: 0, dy: 0 }; | |
| 147 | moved = false; | |
| 148 | try { pill.setPointerCapture(e.pointerId); } catch (x) {} | |
| 149 | }); | |
| 150 | pill.addEventListener("pointermove", function (e) { | |
| 151 | if (!drag) return; | |
| 152 | drag.dx = e.clientX - drag.x; | |
| 153 | drag.dy = e.clientY - drag.y; | |
| 154 | if (!moved && Math.abs(drag.dx) + Math.abs(drag.dy) < 8) return; | |
| 155 | if (!moved) { moved = true; set(false); } | |
| 156 | host.style.right = "auto"; | |
| 157 | host.style.bottom = "auto"; | |
| 158 | host.style.left = Math.max(0, Math.min(window.innerWidth - 44, e.clientX - 22)) + "px"; | |
| 159 | host.style.top = Math.max(0, Math.min(window.innerHeight - 44, e.clientY - 22)) + "px"; | |
| 160 | }); | |
| 161 | function drop(e) { | |
| 162 | if (!drag) return; | |
| 163 | drag = null; | |
| 164 | if (!moved) return; | |
| 165 | moved = false; | |
| 166 | // A release is followed at once by a click on the pill; that is not a | |
| 167 | // request to open. A cancel is not, and a later keyboard click must work. | |
| 168 | if (e.type === "pointerup") until = Date.now() + 50; | |
| 169 | var c = (e.clientY < window.innerHeight / 2 ? "t" : "b") + (e.clientX < window.innerWidth / 2 ? "l" : "r"); | |
| 170 | place(c); | |
| 171 | store(KEY, c); | |
| 172 | } | |
| 173 | pill.addEventListener("pointerup", drop); | |
| 174 | pill.addEventListener("pointercancel", drop); | |
| 175 | pill.addEventListener("keydown", function (e) { | |
| 176 | var t = corner.charAt(0), l = corner.charAt(1); | |
| 177 | if (e.key === "ArrowLeft") l = "l"; | |
| 178 | else if (e.key === "ArrowRight") l = "r"; | |
| 179 | else if (e.key === "ArrowUp") t = "t"; | |
| 180 | else if (e.key === "ArrowDown") t = "b"; | |
| 181 | else return; | |
| 182 | e.preventDefault(); | |
| 183 | place(t + l); | |
| 184 | store(KEY, corner); | |
| 185 | }); | |
| 121 | 186 | // An inline display beats the UA [hidden] rule, so display carries the state. |
| 122 | 187 | function ring(b) { |
| 123 | 188 | b.addEventListener("focus", function () { b.style.outline = "3px solid #58a6ff"; b.style.outlineOffset = "2px"; }); |
| @@ -139,7 +204,10 @@ export const toolbarJs = `(function () { | ||
| 139 | 204 | pill.style.opacity = open ? "1" : ".85"; |
| 140 | 205 | if (open) panel.focus(); |
| 141 | 206 | } |
| 142 | pill.addEventListener("click", function () { set(panel.hidden); }); | |
| 207 | pill.addEventListener("click", function () { | |
| 208 | if (Date.now() < until) return; | |
| 209 | set(panel.hidden); | |
| 210 | }); | |
| 143 | 211 | ring(pill); |
| 144 | 212 | document.addEventListener("keydown", function (e) { |
| 145 | 213 | if (e.key !== "Escape" || panel.hidden) return; |
test/apps-crew-pill.test.ts +1 −1
| @@ -202,6 +202,6 @@ describe("the crew line", () => { | ||
| 202 | 202 | /document\.cookie|querySelector|getElementById|setInterval|setTimeout/, |
| 203 | 203 | ); |
| 204 | 204 | expect(toolbarJs).toContain('aria-live", "polite"'); |
| 205 | expect(toolbarJs.length).toBeLessThan(7 * 1024); | |
| 205 | expect(toolbarJs.length).toBeLessThan(10 * 1024); | |
| 206 | 206 | }); |
| 207 | 207 | }); |
test/apps-toolbar.test.ts +1 −1
| @@ -366,7 +366,7 @@ describe("the forge pill", () => { | ||
| 366 | 366 | expect(res.headers.get("content-type")).toContain("javascript"); |
| 367 | 367 | const code = await res.text(); |
| 368 | 368 | expect(code).toBe(toolbarJs); |
| 369 | expect(code.length).toBeLessThan(7 * 1024); | |
| 369 | expect(code.length).toBeLessThan(10 * 1024); | |
| 370 | 370 | expect(code).not.toMatch(/\beval\b|new Function|\son[a-z]+\s*=/); |
| 371 | 371 | expect(code).toContain("attachShadow"); |
| 372 | 372 | }); |
test/toolbar-move.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * The pill can be moved (#3448): dragged to a corner, or walked there with | |
| 3 | * the arrow keys; the corner is kept per app host and the panel opens | |
| 4 | * toward the inside of the screen. | |
| 5 | */ | |
| 6 | ||
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { toolbarJs } from "../src/apps/toolbar.ts"; | |
| 9 | ||
| 10 | type L = Record<string, ((e?: any) => void)[]>; | |
| 11 | interface El { | |
| 12 | tag: string; | |
| 13 | style: Record<string, string>; | |
| 14 | children: El[]; | |
| 15 | listeners: L; | |
| 16 | hidden: boolean; | |
| 17 | textContent: string; | |
| 18 | [k: string]: any; | |
| 19 | } | |
| 20 | const make = (tag: string): El => ({ | |
| 21 | tag, | |
| 22 | style: {}, | |
| 23 | children: [], | |
| 24 | listeners: {}, | |
| 25 | hidden: false, | |
| 26 | textContent: "", | |
| 27 | attrs: {} as Record<string, string>, | |
| 28 | setAttribute(k: string, v: string) { this.attrs[k] = v; }, | |
| 29 | appendChild(c: El) { this.children.push(c); return c; }, | |
| 30 | addEventListener(t: string, f: (e?: any) => void) { (this.listeners[t] ??= []).push(f); }, | |
| 31 | focus() {}, | |
| 32 | attachShadow() { return make("shadow"); }, | |
| 33 | }); | |
| 34 | const all = (el: El): El[] => [el, ...el.children.flatMap(all)]; | |
| 35 | ||
| 36 | function boot(store: Record<string, string> | null, width = 400, height = 800) { | |
| 37 | let root!: El; | |
| 38 | const host = { el: null as El | null }; | |
| 39 | const doc = { | |
| 40 | currentScript: { src: "https://forge.test/assets/t.js", dataset: { app: "notes", env: "production" } }, | |
| 41 | createElement: (tag: string) => { | |
| 42 | const el = make(tag); | |
| 43 | if (tag === "div" && !host.el) { | |
| 44 | host.el = el; | |
| 45 | el.attachShadow = () => (root = make("shadow")); | |
| 46 | } | |
| 47 | return el; | |
| 48 | }, | |
| 49 | addEventListener() {}, | |
| 50 | body: { appendChild() {} }, | |
| 51 | }; | |
| 52 | const win: Record<string, any> = { innerWidth: width, innerHeight: height }; | |
| 53 | if (store) | |
| 54 | win.localStorage = { | |
| 55 | getItem: (k: string) => store[k] ?? null, | |
| 56 | setItem: (k: string, v: string) => { store[k] = v; }, | |
| 57 | }; | |
| 58 | new Function("window", "document", "URL", toolbarJs)(win, doc, URL); | |
| 59 | const els = all(root); | |
| 60 | const pill = els.find((e) => e.tag === "button")!; | |
| 61 | const box = root.children[0]!; | |
| 62 | const panel = els.find((e) => e.attrs.role === "region")!; | |
| 63 | const fire = (el: El, t: string, e: object = {}) => el.listeners[t]!.forEach((f) => f({ preventDefault() {}, type: t, ...e })); | |
| 64 | return { host: host.el!, pill, box, panel, fire }; | |
| 65 | } | |
| 66 | ||
| 67 | describe("moving the pill", () => { | |
| 68 | test("starts bottom-right and the panel opens to its left", () => { | |
| 69 | const { host, box } = boot({}); | |
| 70 | expect(host.style.right).toBe("12px"); | |
| 71 | expect(host.style.bottom).toBe("12px"); | |
| 72 | expect(host.style.top).toBe("auto"); | |
| 73 | expect(box.style.flexDirection).toBe("row-reverse"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a drag snaps to the nearest corner and is remembered", () => { | |
| 77 | const store: Record<string, string> = {}; | |
| 78 | const { host, box, pill, fire } = boot(store); | |
| 79 | fire(pill, "pointerdown", { clientX: 380, clientY: 780 }); | |
| 80 | fire(pill, "pointermove", { clientX: 100, clientY: 60 }); | |
| 81 | expect(host.style.left).toBe("78px"); // follows the pointer while dragging | |
| 82 | fire(pill, "pointerup", { clientX: 100, clientY: 60 }); | |
| 83 | expect(host.style.top).toBe("12px"); | |
| 84 | expect(host.style.left).toBe("12px"); | |
| 85 | expect(host.style.right).toBe("auto"); | |
| 86 | expect(box.style.flexDirection).toBe("row"); | |
| 87 | expect(box.style.alignItems).toBe("flex-start"); | |
| 88 | expect(store["forge-pill-corner"]).toBe("tl"); | |
| 89 | // The next page load puts it there again. | |
| 90 | expect(boot(store).host.style.left).toBe("12px"); | |
| 91 | expect(boot(store).host.style.top).toBe("12px"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a press that barely moves is a tap, not a drag", () => { | |
| 95 | const store: Record<string, string> = {}; | |
| 96 | const { pill, fire, panel } = boot(store); | |
| 97 | fire(pill, "pointerdown", { clientX: 380, clientY: 780 }); | |
| 98 | fire(pill, "pointermove", { clientX: 382, clientY: 781 }); | |
| 99 | fire(pill, "pointerup", { clientX: 382, clientY: 781 }); | |
| 100 | fire(pill, "click"); | |
| 101 | expect(store["forge-pill-corner"]).toBeUndefined(); | |
| 102 | expect(panel.hidden).toBe(false); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the click that ends a drag does not open the panel", () => { | |
| 106 | const { pill, fire, panel } = boot({}); | |
| 107 | fire(pill, "pointerdown", { clientX: 380, clientY: 780 }); | |
| 108 | fire(pill, "pointermove", { clientX: 20, clientY: 780 }); | |
| 109 | fire(pill, "pointerup", { clientX: 20, clientY: 780 }); | |
| 110 | fire(pill, "click"); | |
| 111 | expect(panel.hidden).toBe(true); | |
| 112 | }); | |
| 113 | ||
| 114 | test("arrow keys walk it from corner to corner", () => { | |
| 115 | const store: Record<string, string> = {}; | |
| 116 | const { host, pill, fire } = boot(store); | |
| 117 | fire(pill, "keydown", { key: "ArrowLeft" }); | |
| 118 | expect(store["forge-pill-corner"]).toBe("bl"); | |
| 119 | expect(host.style.left).toBe("12px"); | |
| 120 | fire(pill, "keydown", { key: "ArrowUp" }); | |
| 121 | expect(store["forge-pill-corner"]).toBe("tl"); | |
| 122 | fire(pill, "keydown", { key: "ArrowRight" }); | |
| 123 | fire(pill, "keydown", { key: "ArrowDown" }); | |
| 124 | expect(store["forge-pill-corner"]).toBe("br"); | |
| 125 | fire(pill, "keydown", { key: "a" }); | |
| 126 | expect(store["forge-pill-corner"]).toBe("br"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a garbage stored corner is ignored; no storage at all still works", () => { | |
| 130 | expect(boot({ "forge-pill-corner": "<x>" }).host.style.right).toBe("12px"); | |
| 131 | const { host, pill, fire } = boot(null); | |
| 132 | fire(pill, "keydown", { key: "ArrowLeft" }); | |
| 133 | expect(host.style.left).toBe("12px"); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a cancelled drag leaves the pill answering a later keyboard click", () => { | |
| 137 | const { pill, fire, panel } = boot({}); | |
| 138 | fire(pill, "pointerdown", { clientX: 380, clientY: 780 }); | |
| 139 | fire(pill, "pointermove", { clientX: 20, clientY: 780 }); | |
| 140 | fire(pill, "pointercancel", { clientX: 20, clientY: 780 }); | |
| 141 | fire(pill, "click"); | |
| 142 | expect(panel.hidden).toBe(false); | |
| 143 | }); | |
| 144 | ||
| 145 | test("after a released drag, the next click a moment later opens the panel", async () => { | |
| 146 | const { pill, fire, panel } = boot({}); | |
| 147 | fire(pill, "pointerdown", { clientX: 380, clientY: 780 }); | |
| 148 | fire(pill, "pointermove", { clientX: 20, clientY: 780 }); | |
| 149 | fire(pill, "pointerup", { clientX: 20, clientY: 780 }); | |
| 150 | await Bun.sleep(80); | |
| 151 | fire(pill, "click"); | |
| 152 | expect(panel.hidden).toBe(false); | |
| 153 | }); | |
| 154 | ||
| 155 | test("the pill has no hide item and no hint: it only moves", () => { | |
| 156 | expect(toolbarJs).not.toMatch(/Hide for this page|Move: drag/); | |
| 157 | }); | |
| 158 | ||
| 159 | test("the panel is bounded to the screen", () => { | |
| 160 | expect(toolbarJs).toContain("max-width:min(280px,calc(100vw - 64px))"); | |
| 161 | expect(toolbarJs).toContain("max-height:calc(100vh - 88px)"); | |
| 162 | }); | |
| 163 | }); |
new above
offline
Hiding strands focus and has no restore path; the issue did not ask for it. Remove it, or move focus back to the document and explain why.