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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1502-the-forge-pill-says-where-you-are-app-pr → main 10 commits fork 5 threads · 5 unresolved
README.md +6
| @@ -475,6 +475,12 @@ environment. Browsers resolve `*.localhost` to loopback, so a laptop forge | ||
| 475 | 475 | needs no DNS at all; elsewhere point a wildcard at it and set |
| 476 | 476 | `FORGE_APP_DOMAIN`. |
| 477 | 477 | |
| 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 | ||
| 478 | 484 | With a sentence and **Pick for me**, the sentence becomes a plan before the |
| 479 | 485 | page comes back: `src/crew/plan.ts` makes one non-interactive call to |
| 480 | 486 | `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 () { | ||
| 27 | 27 | box.style.cssText = "font:13px/1.3 system-ui,sans-serif;color:#fff;display:flex;align-items:center;gap:8px;flex-direction:row-reverse"; |
| 28 | 28 | var pill = document.createElement("button"); |
| 29 | 29 | pill.type = "button"; |
| 30 | pill.setAttribute("aria-label", "Forge"); | |
| 30 | var d = me.dataset || {}; | |
| 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"); | |
| 31 | 33 | pill.setAttribute("aria-expanded", "false"); |
outdated
|
||
| 32 | 34 | 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)"; | |
| 35 | 36 | var panel = document.createElement("div"); |
| 36 | 37 | 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. |
||
| 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"; | |
| 38 | 42 | function line(text, bold) { |
| 39 | 43 | var el = document.createElement("div"); |
| 40 | 44 | el.textContent = text; |
| @@ -57,10 +61,17 @@ export const toolbarJs = `(function () { | ||
| 57 | 61 | function set(open) { |
| 58 | 62 | panel.hidden = !open; |
| 59 | 63 | 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(); | |
| 61 | 66 | } |
| 62 | 67 | 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 | }); | |
| 64 | 75 | document.addEventListener("pointerdown", function (e) { |
| 65 | 76 | if (panel.hidden) return; |
| 66 | 77 | var path = e.composedPath ? e.composedPath() : []; |
test/apps-pill-where.test.ts +19
| @@ -120,6 +120,25 @@ describe("the pill's script", () => { | ||
| 120 | 120 | expect(toolbarJs).toContain('pill.addEventListener("click"'); |
| 121 | 121 | }); |
| 122 | 122 | |
| 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 | ||
| 123 | 142 | test("puts text in with textContent and checks the home href against the forge origin", () => { |
| 124 | 143 | expect(toolbarJs).toContain("textContent = text"); |
| 125 | 144 | expect(toolbarJs).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/); |
test/apps-toolbar.test.ts +85
| @@ -209,6 +209,91 @@ describe("the forge pill", () => { | ||
| 209 | 209 | }); |
| 210 | 210 | }); |
| 211 | 211 | |
| 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 | ||
| 212 | 297 | describe("the opt-out, from forge.json to the proxy", () => { |
| 213 | 298 | let dataDir: string; |
| 214 | 299 | let app: App; |
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.