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

The forge pill says where you are: app, production or preview of pull #n with its title, and a link home #1696

Merged opened this

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

Merged

scout/forge:scout/1502-the-forge-pill-says-where-you-are-app-pr → main 10 commits fork 5 threads · 5 unresolved

  • Merged. scout/1502-the-forge-pill-says-where-you-are-app-pr was merged into main as bdc4f9f.

Review

Sign in to review this change.
4 files changed +127 −6
README.md +6
@@ -475,6 +475,12 @@ environment. Browsers resolve `*.localhost` to loopback, so a laptop forge
475475 needs no DNS at all; elsewhere point a wildcard at it and set
476476 `FORGE_APP_DOMAIN`.
477477
478 The forge pill on an app's pages says where you are: its accessible name reads
479 `notes, production` or `notes, preview of pull #7`, and pressing it opens a
480 panel with the app, the environment, and a link home. The pull's title is shown
481 only to people who may read the pull. A tap outside the open panel, or Escape,
482 closes it.
483
478484 With a sentence and **Pick for me**, the sentence becomes a plan before the
479485 page comes back: `src/crew/plan.ts` makes one non-interactive call to
480486 `claude` — the quickest model, a fixed JSON shape, at most 3 seconds — for a
src/apps/toolbar.ts +17 −6
@@ -27,14 +27,18 @@ export const toolbarJs = `(function () {
2727 box.style.cssText = "font:13px/1.3 system-ui,sans-serif;color:#fff;display:flex;align-items:center;gap:8px;flex-direction:row-reverse";
2828 var pill = document.createElement("button");
2929 pill.type = "button";
30 pill.setAttribute("aria-label", "Forge");
30 var d = me.dataset || {};
outdated

aria-label stays the constant "Forge". Build it from the app and environment (for example notes, preview of pull #7) so the pill itself says where you are to a screen reader.

31 var where = d.env === "production" ? "production" : d.pull ? "preview of pull #" + d.pull : "";
32 pill.setAttribute("aria-label", [d.app, where].filter(Boolean).join(", ") || "Forge");
3133 pill.setAttribute("aria-expanded", "false");
outdated

all:unset kills the focus outline and the 28px/.55-opacity circle is too small and faint for a thumb. Add a visible focus cue and a 44px target.

3234 pill.textContent = "\\u2692";
33 pill.style.cssText = "all:unset;box-sizing:border-box;cursor:pointer;width:28px;height:28px;border-radius:14px;background:#1f2328;color:#fff;text-align:center;line-height:28px;font-size:14px;opacity:.55;box-shadow:0 1px 4px rgba(0,0,0,.4)";
34 var d = me.dataset || {};
35 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)";
3536 var panel = document.createElement("div");
3637 panel.hidden = true;
outdated

The panel now carries the information the issue asks for, but it has no role or label and focus never moves to it. Give it a role (e.g. role="region" with an aria-label) and return focus to the pill on Escape.

37 panel.style.cssText = "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));display:flex;flex-direction:column;gap:2px";
38 panel.setAttribute("role", "region");
39 panel.setAttribute("aria-label", "Where you are");
40 panel.tabIndex = -1;
41 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));display:flex;flex-direction:column;gap:2px";
3842 function line(text, bold) {
3943 var el = document.createElement("div");
4044 el.textContent = text;
@@ -57,10 +61,17 @@ export const toolbarJs = `(function () {
5761 function set(open) {
5862 panel.hidden = !open;
5963 pill.setAttribute("aria-expanded", open ? "true" : "false");
60 pill.style.opacity = open ? "1" : ".55";
64 pill.style.opacity = open ? "1" : ".85";
65 if (open) panel.focus();
6166 }
6267 pill.addEventListener("click", function () { set(panel.hidden); });
63 document.addEventListener("keydown", function (e) { if (e.key === "Escape") set(false); });
68 pill.addEventListener("focus", function () { pill.style.outline = "3px solid #58a6ff"; pill.style.outlineOffset = "2px"; });
69 pill.addEventListener("blur", function () { pill.style.outline = "none"; });
70 document.addEventListener("keydown", function (e) {
71 if (e.key !== "Escape" || panel.hidden) return;
outdated

Add a pointerdown listener on the document that closes the panel when the target is outside it. A phone has no Escape key, so the only way to close the panel there is to tap the pill again.

72 set(false);
73 pill.focus();
74 });
6475 document.addEventListener("pointerdown", function (e) {
6576 if (panel.hidden) return;
6677 var path = e.composedPath ? e.composedPath() : [];
test/apps-pill-where.test.ts +19
@@ -120,6 +120,25 @@ describe("the pill's script", () => {
120120 expect(toolbarJs).toContain('pill.addEventListener("click"');
121121 });
122122
123 test("the aria-label follows the environment, from the same data attributes", () => {
124 function label(data: Record<string, string>): string {
125 let name = "";
126 const pill = { type: "", textContent: "", style: {}, setAttribute: (k: string, v: string) => { if (k === "aria-label") name = v; }, addEventListener() {} };
127 const el = () => ({ style: {}, appendChild() {}, setAttribute() {}, addEventListener() {}, set hidden(_v: boolean) {} });
128 const doc = {
129 currentScript: { src: "https://forge.test/assets/x.js", dataset: data },
130 createElement: (t: string) => (t === "button" ? pill : { ...el(), attachShadow: () => ({ appendChild() {} }) }),
131 addEventListener() {},
132 body: { appendChild() {} },
133 };
134 new Function("window", "document", "URL", toolbarJs)({}, doc, URL);
135 return name;
136 }
137 expect(label({ app: "notes", env: "production" })).toBe("notes, production");
138 expect(label({ app: "notes", env: "preview", pull: "7" })).toBe("notes, preview of pull #7");
139 expect(label({})).toBe("Forge");
140 });
141
123142 test("puts text in with textContent and checks the home href against the forge origin", () => {
124143 expect(toolbarJs).toContain("textContent = text");
125144 expect(toolbarJs).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/);
test/apps-toolbar.test.ts +85
@@ -209,6 +209,91 @@ describe("the forge pill", () => {
209209 });
210210 });
211211
212 describe("the pill's keyboard and touch behaviour", () => {
213 describe("the panel the script builds", () => {
214 interface El {
215 tag: string;
216 attrs: Record<string, string>;
217 style: { cssText: string; [k: string]: string };
218 children: El[];
219 listeners: Record<string, ((e?: unknown) => void)[]>;
220 hidden: boolean;
221 textContent: string;
222 href?: string;
223 tabIndex?: number;
224 focused: boolean;
225 setAttribute(k: string, v: string): void;
226 appendChild(c: El): El;
227 addEventListener(t: string, f: (e?: unknown) => void): void;
228 focus(): void;
229 attachShadow(): El;
230 }
231 const make = (tag: string): El => ({
232 tag,
233 attrs: {},
234 style: { cssText: "" },
235 children: [],
236 listeners: {},
237 hidden: false,
238 textContent: "",
239 focused: false,
240 setAttribute(k, v) { this.attrs[k] = v; },
241 appendChild(c) { this.children.push(c); return c; },
242 addEventListener(t, f) { (this.listeners[t] ??= []).push(f); },
243 focus() { this.focused = true; },
244 attachShadow() { return make("shadow"); },
245 });
246 const all = (el: El): El[] => [el, ...el.children.flatMap(all)];
247 // The script keeps its tree in the shadow root; capture it.
248 function tree(data: Record<string, string>) {
249 let root!: El;
250 const orig = make;
251 const patched = (tag: string): El => {
252 const el = orig(tag);
253 el.attachShadow = () => (root = make("shadow"));
254 return el;
255 };
256 const doc = {
257 currentScript: { src: "https://forge.test/assets/t.js", dataset: data },
258 createElement: patched,
259 body: { appendChild: () => undefined },
260 listeners: {} as Record<string, ((e: unknown) => void)[]>,
261 addEventListener(t: string, f: (e: unknown) => void) { (this.listeners[t] ??= []).push(f); },
262 };
263 new Function("window", "document", "URL", toolbarJs)({}, doc, URL);
264 const els = all(root);
265 const pill = els.find((e) => e.tag === "button")!;
266 const panel = els.find((e) => e.attrs.role === "region")!;
267 return { doc, pill, panel, lines: panel.children };
268 }
269
270
271 test("the panel is a labelled region, opens with focus inside, and Escape returns focus to the pill", () => {
272 const { doc, pill, panel } = tree({ app: "notes", env: "production" });
273 expect(panel.attrs["aria-label"]).toBeTruthy();
274 expect(panel.hidden).toBe(true);
275 pill.listeners.click![0]!();
276 expect(panel.hidden).toBe(false);
277 expect(panel.focused).toBe(true);
278 expect(pill.attrs["aria-expanded"]).toBe("true");
279 pill.focused = false;
280 doc.listeners.keydown![0]!({ key: "Escape" });
281 expect(panel.hidden).toBe(true);
282 expect(pill.focused).toBe(true);
283 });
284
285 test("the pill has a visible focus ring and a thumb-sized, legible target", () => {
286 const { pill } = tree({ app: "notes" });
287 pill.listeners.focus![0]!();
288 expect(pill.style.outline).toContain("solid");
289 pill.listeners.blur![0]!();
290 expect(pill.style.outline).toBe("none");
291 expect(pill.style.cssText).toContain("width:44px;height:44px");
292 expect(Number(/opacity:([.\d]+)/.exec(pill.style.cssText)![1])).toBeGreaterThanOrEqual(0.8);
293 });
294 });
295 });
296
212297 describe("the opt-out, from forge.json to the proxy", () => {
213298 let dataDir: string;
214299 let app: App;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs