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

Merged opened this in answer to

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

Merged

scout/forge:scout/3439-the-forge-pill-can-be-moved-out-of-the-w → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/3439-the-forge-pill-can-be-moved-out-of-the-w was merged into main as 27399f3.

Review

Sign in to review this change.
5 files changed +247 −9
README.md +7
@@ -911,6 +911,13 @@ environment. Browsers resolve `*.localhost` to loopback, so a laptop forge
911911 needs no DNS at all; elsewhere point a wildcard at it and set
912912 `FORGE_APP_DOMAIN`.
913913
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
914921 The forge pill on an app's pages says where you are: its accessible name reads
915922 `notes, production` or `notes, preview of pull #7`, and pressing it opens a
916923 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 @@
44 * immutable, so the page needs no inline script and an app's CSP that
55 * allows no foreign scripts simply leaves the pill out.
66 *
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
910 * (never a `style` attribute or a `<style>` element), which a strict
1011 * `style-src` does not block.
1112 */
@@ -22,10 +23,10 @@ export const toolbarJs = `(function () {
2223 try { home = new URL(me.src).origin + "/"; } catch (e) {}
2324 var host = document.createElement("div");
2425 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";
2627 var root = host.attachShadow({ mode: "closed" });
2728 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";
2930 var pill = document.createElement("button");
3031 pill.type = "button";
3132 var d = me.dataset || {};
@@ -33,13 +34,13 @@ export const toolbarJs = `(function () {
3334 pill.setAttribute("aria-label", [d.app, where].filter(Boolean).join(", ") || "Forge");
3435 pill.setAttribute("aria-expanded", "false");
3536 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";
3738 var panel = document.createElement("div");
3839 panel.hidden = true;
3940 panel.setAttribute("role", "region");
4041 panel.setAttribute("aria-label", "Where you are");
4142 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";
4344 function line(text, bold) {
4445 var el = document.createElement("div");
4546 el.textContent = text;
@@ -118,6 +119,70 @@ export const toolbarJs = `(function () {
118119 })
119120 .catch(function () {});
120121 } 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) {} }
outdated

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.

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 });
121186 // An inline display beats the UA [hidden] rule, so display carries the state.
122187 function ring(b) {
123188 b.addEventListener("focus", function () { b.style.outline = "3px solid #58a6ff"; b.style.outlineOffset = "2px"; });
@@ -139,7 +204,10 @@ export const toolbarJs = `(function () {
139204 pill.style.opacity = open ? "1" : ".85";
140205 if (open) panel.focus();
141206 }
142 pill.addEventListener("click", function () { set(panel.hidden); });
207 pill.addEventListener("click", function () {
208 if (Date.now() < until) return;
209 set(panel.hidden);
210 });
143211 ring(pill);
144212 document.addEventListener("keydown", function (e) {
145213 if (e.key !== "Escape" || panel.hidden) return;
test/apps-crew-pill.test.ts +1 −1
@@ -202,6 +202,6 @@ describe("the crew line", () => {
202202 /document\.cookie|querySelector|getElementById|setInterval|setTimeout/,
203203 );
204204 expect(toolbarJs).toContain('aria-live", "polite"');
205 expect(toolbarJs.length).toBeLessThan(7 * 1024);
205 expect(toolbarJs.length).toBeLessThan(10 * 1024);
206206 });
207207 });
test/apps-toolbar.test.ts +1 −1
@@ -366,7 +366,7 @@ describe("the forge pill", () => {
366366 expect(res.headers.get("content-type")).toContain("javascript");
367367 const code = await res.text();
368368 expect(code).toBe(toolbarJs);
369 expect(code.length).toBeLessThan(7 * 1024);
369 expect(code.length).toBeLessThan(10 * 1024);
370370 expect(code).not.toMatch(/\beval\b|new Function|\son[a-z]+\s*=/);
371371 expect(code).toContain("attachShadow");
372372 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs