Public: anyone can read this; the crew can change it.
Design: top bar diet #2703
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2699-design-top-bar-diet → main 3 commits fork
3 files
changed +75
−47
src/web/client.ts +2 −2
| @@ -1262,7 +1262,7 @@ export const shellJs = /* js */ ` | ||
| 1262 | 1262 | if (!m || !m.matches || !m.matches('details.menu[open]')) return; |
| 1263 | 1263 | closeMenus(m); |
| 1264 | 1264 | var first = m.querySelector('.menu-panel .menu-item, .menu-panel input'); |
| 1265 | if (first && !m.querySelector('.theme-option')) first.focus(); | |
| 1265 | if (first && !first.closest('.theme-option')) first.focus(); | |
| 1266 | 1266 | }, true); |
| 1267 | 1267 | doc.addEventListener('keydown', function (e) { |
| 1268 | 1268 | if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; |
| @@ -1275,7 +1275,7 @@ export const shellJs = /* js */ ` | ||
| 1275 | 1275 | }); |
| 1276 | 1276 | |
| 1277 | 1277 | // ---- theme: apply instantly, persist once the choice settles ---- |
| 1278 | var themeForm = doc.querySelector('.theme-menu form'); | |
| 1278 | var themeForm = doc.querySelector('.theme-form'); | |
| 1279 | 1279 | if (themeForm) { |
| 1280 | 1280 | var apply = themeForm.querySelector('.theme-apply'); |
| 1281 | 1281 | if (apply) apply.hidden = true; |
src/web/layout.ts +36 −43
| @@ -130,7 +130,8 @@ export interface PageOptions { | ||
| 130 | 130 | |
| 131 | 131 | // ---- topbar pieces ---- |
| 132 | 132 | |
| 133 | function themeMenu(active: ThemeName | undefined): Html { | |
| 133 | /** The theme choice as a panel section: lives in a menu, never in the bar. */ | |
| 134 | function themeForm(active: ThemeName | undefined): Html { | |
| 134 | 135 | const option = (value: string, label: string, checked: boolean) => |
| 135 | 136 | html`<label class="theme-option"> |
| 136 | 137 | <input |
| @@ -143,11 +144,7 @@ function themeMenu(active: ThemeName | undefined): Html { | ||
| 143 | 144 | ${label} |
| 144 | 145 | <span class="tick" aria-hidden="true">${icon("check")}</span> |
| 145 | 146 | </label>`; |
| 146 | return html`<details class="menu theme-menu"> | |
| 147 | <summary class="btn btn-icon" aria-label="Choose theme" title="Theme"> | |
| 148 | ${themeGlyph} | |
| 149 | </summary> | |
| 150 | <form class="menu-panel" method="post" action="/theme"> | |
| 147 | return html`<form class="theme-form" method="post" action="/theme"> | |
| 151 | 148 | <span class="menu-title">Theme</span> |
| 152 | 149 | ${option("system", "System", active === undefined)} |
| 153 | 150 | ${THEME_NAMES.map((name) => |
| @@ -156,8 +153,22 @@ function themeMenu(active: ThemeName | undefined): Html { | ||
| 156 | 153 | <button class="btn theme-apply" data-size="sm" type="submit"> |
| 157 | 154 | Apply |
| 158 | 155 | </button> |
| 159 | </form> | |
| 160 | </details>`; | |
| 156 | </form>`; | |
| 157 | } | |
| 158 | ||
| 159 | /** Keyboard help: js-only, since the shortcuts it lists only exist then. */ | |
| 160 | const helpItem = html`<button class="menu-item js-only hide-sm" type="button" data-help> | |
| 161 | ${icon("help")}<span class="grow">Keyboard shortcuts</span> | |
| 162 | </button>`; | |
| 163 | ||
| 164 | /** Someone not signed in has no user menu; theme and help share this one. */ | |
| 165 | function guestMenu(theme: ThemeName | undefined): Html { | |
| 166 | return menu({ | |
| 167 | trigger: themeGlyph, | |
| 168 | items: [helpItem, themeForm(theme)], | |
| 169 | label: "Appearance and help", | |
| 170 | triggerOptions: { extra: "btn-icon" }, | |
| 171 | }); | |
| 161 | 172 | } |
| 162 | 173 | |
| 163 | 174 | /** A whole forge for someone (`/forges/new`): any signed-in person's. */ |
| @@ -194,8 +205,13 @@ function newMenu(ctx: Ctx): Html { | ||
| 194 | 205 | }); |
| 195 | 206 | } |
| 196 | 207 | |
| 197 | function userMenu(ctx: Ctx, user: User, signOut: MenuItem): Html { | |
| 198 | const items: MenuItem[] = [ | |
| 208 | function userMenu( | |
| 209 | ctx: Ctx, | |
| 210 | user: User, | |
| 211 | theme: ThemeName | undefined, | |
| 212 | signOut: MenuItem, | |
| 213 | ): Html { | |
| 214 | const items: (MenuItem | Html)[] = [ | |
| 199 | 215 | { label: "Your profile", href: `/${user.username}`, icon: "user" }, |
| 200 | 216 | { label: "Your repositories", href: "/", icon: "repo" }, |
| 201 | 217 | { label: "Apps", href: "/apps", icon: "rocket" }, |
| @@ -225,6 +241,12 @@ function userMenu(ctx: Ctx, user: User, signOut: MenuItem): Html { | ||
| 225 | 241 | ] |
| 226 | 242 | : []), |
| 227 | 243 | { separator: true, label: "" }, |
| 244 | ...(user.isAdmin | |
| 245 | ? [{ label: "Flow", href: "/flow", icon: "activity" as const }] | |
| 246 | : []), | |
| 247 | helpItem, | |
| 248 | themeForm(theme), | |
| 249 | { separator: true, label: "" }, | |
| 228 | 250 | signOut, |
| 229 | 251 | ]; |
| 230 | 252 | return menu({ |
| @@ -257,25 +279,6 @@ function searchBar(): Html { | ||
| 257 | 279 | >`; |
| 258 | 280 | } |
| 259 | 281 | |
| 260 | /** | |
| 261 | * Keyboard help. The shortcuts it lists only exist when the script runs, so | |
| 262 | * the button that opens it only exists then too — `js-only` is revealed by | |
| 263 | * the shell script rather than shown and then found to be dead. | |
| 264 | */ | |
| 265 | function helpButton(): Html { | |
| 266 | // Keyboard shortcuts have no meaning on a phone; the button would only | |
| 267 | // cost the width that pushes the page sideways. | |
| 268 | return html`<button | |
| 269 | class="btn btn-icon js-only hide-sm" | |
| 270 | type="button" | |
| 271 | data-help | |
| 272 | aria-label="Keyboard shortcuts" | |
| 273 | title="Keyboard shortcuts" | |
| 274 | > | |
| 275 | ${icon("help")} | |
| 276 | </button>`; | |
| 277 | } | |
| 278 | ||
| 279 | 282 | // ---- palette index ---- |
| 280 | 283 | |
| 281 | 284 | /** The palette's own verbs: where to go, what to make, what to set. */ |
| @@ -459,22 +462,12 @@ function topbar( | ||
| 459 | 462 | )} |
| 460 | 463 | <span class="spacer"></span> |
| 461 | 464 | <span class="topbar-tools"> |
| 462 | ${when( | |
| 463 | user?.isAdmin === true, | |
| 464 | () => | |
| 465 | html`<a | |
| 466 | id="flow-link" | |
| 467 | class="btn" | |
| 468 | data-size="sm" | |
| 469 | href="/flow" | |
| 470 | title="The pipeline and its bottleneck" | |
| 471 | >${icon("activity")}<span class="hide-sm">Flow</span></a | |
| 472 | >`, | |
| 473 | )}${searchBar()}<span class="hide-sm">${helpButton()}</span>${themeMenu(theme)} | |
| 465 | ${searchBar()} | |
| 474 | 466 | ${user |
| 475 | 467 | ? html`<span class="hide-sm">${newMenu(ctx)}</span>${userMenu( |
| 476 | 468 | ctx, |
| 477 | 469 | user, |
| 470 | theme, | |
| 478 | 471 | ctx.auth.via === "edge" |
| 479 | 472 | ? { |
| 480 | 473 | label: "Sign out", |
| @@ -483,12 +476,12 @@ function topbar( | ||
| 483 | 476 | } |
| 484 | 477 | : { label: "Sign out", post: "/logout", icon: "signOut" }, |
| 485 | 478 | )}` |
| 486 | : btn("Sign in", { | |
| 479 | : html`${guestMenu(theme)}${btn("Sign in", { | |
| 487 | 480 | href: |
| 488 | 481 | ctx.app.config.edgeToken === undefined |
| 489 | 482 | ? "/signin" |
| 490 | 483 | : edgeLoginUrl(ctx.url.pathname + ctx.url.search), |
| 491 | })} | |
| 484 | })}`} | |
| 492 | 485 | </span> |
| 493 | 486 | </header>`; |
| 494 | 487 | } |
test/flow-pipeline.test.ts +37 −2
| @@ -23,6 +23,7 @@ import { createRun } from "../src/core/runs.ts"; | ||
| 23 | 23 | import { createUser, type User } from "../src/core/users.ts"; |
| 24 | 24 | import { createToken } from "../src/core/tokens.ts"; |
| 25 | 25 | import { bottleneck, readPipeline, span, verdict } from "../src/flow/pipeline.ts"; |
| 26 | import { shellJs } from "../src/web/client.ts"; | |
| 26 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | 28 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; |
| 28 | 29 | import { forgeCommands, Session } from "../src/tui/forge.ts"; |
| @@ -247,16 +248,50 @@ describe("/flow: the forge shows its own bottleneck", () => { | ||
| 247 | 248 | for (const key of ["ready", "building", "checking", "review", "approved", "landed", "live"]) |
| 248 | 249 | expect(page).toContain(`data-stage="${key}"`); |
| 249 | 250 | expect(page).toContain('href="/settings/capacity"'); |
| 250 | expect(page).toContain('id="flow-link"'); | |
| 251 | // An admin reaches Flow from every page, through the user menu. | |
| 252 | expect(await (await get("/", cookie)).text()).toContain('href="/flow"'); | |
| 251 | 253 | }); |
| 252 | 254 | |
| 253 | 255 | test("nobody but a forge admin reads it", async () => { |
| 254 | 256 | const bob = await login("bob"); |
| 255 | 257 | expect((await get("/flow", bob)).status).toBe(404); |
| 256 | expect(await (await get("/", bob)).text()).not.toContain('id="flow-link"'); | |
| 258 | expect(await (await get("/", bob)).text()).not.toContain('href="/flow"'); | |
| 257 | 259 | expect((await get("/flow", "")).status).not.toBe(200); |
| 258 | 260 | }); |
| 259 | 261 | |
| 262 | test("the top bar holds search, New and the user menu; Flow, help and theme live in menus", async () => { | |
| 263 | expect(alice.isAdmin).toBe(true); | |
| 264 | const page = await (await get("/", await login("alice"))).text(); | |
| 265 | const bar = page.slice(page.indexOf('<header class="topbar"'), page.indexOf("</header>")); | |
| 266 | // Nothing but menus and search sits in the bar: no bare Flow link, no help button. | |
| 267 | expect(bar).not.toContain("flow-link"); | |
| 268 | expect(bar).not.toContain("theme-menu"); | |
| 269 | expect(bar.match(/<details/g)?.length).toBe(2); | |
| 270 | // They are still one open away, in the user menu. | |
| 271 | const panel = bar.slice(bar.lastIndexOf('<details')); | |
| 272 | expect(panel).toContain('href="/flow"'); | |
| 273 | expect(panel).toContain("data-help"); | |
| 274 | expect(panel).toContain('action="/theme"'); | |
| 275 | }); | |
| 276 | ||
| 277 | test("a guest's bar has search, one appearance menu and Sign in; no bare theme button", async () => { | |
| 278 | const page = await (await get("/", "")).text(); | |
| 279 | const bar = page.slice(page.indexOf('<header class="topbar"'), page.indexOf("</header>")); | |
| 280 | expect(bar).toContain("Sign in"); | |
| 281 | expect(bar).not.toContain("theme-menu"); | |
| 282 | expect(bar.replace(/<details[\s\S]*?<\/details>/g, "")).not.toContain("<button"); | |
| 283 | expect(bar.match(/<details/g)?.length).toBe(1); | |
| 284 | const panel = bar.slice(bar.indexOf("<details")); | |
| 285 | expect(panel).toContain("data-help"); | |
| 286 | expect(panel).toContain('action="/theme"'); | |
| 287 | }); | |
| 288 | ||
| 289 | test("the shell script wires the theme form by its new class and guards focus on theme options", () => { | |
| 290 | expect(shellJs).toContain("doc.querySelector('.theme-form')"); | |
| 291 | expect(shellJs).not.toContain(".theme-menu form"); | |
| 292 | expect(shellJs).toContain("first.closest('.theme-option')"); | |
| 293 | }); | |
| 294 | ||
| 260 | 295 | const flowCommand = (user?: User) => { |
| 261 | 296 | const pushed: Screen[] = []; |
| 262 | 297 | const ctx = { push: (s: Screen) => pushed.push(s) } as unknown as Ctx; |
new above
offline