Public: anyone can read this; the crew can change it.
Quieter pages, nothing lost: a pull, an issue list and a repository head show less at rest, and every control stays one step away #8722
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8719-quieter-pages-nothing-lost-a-pull-an-iss → main 3 commits fork
8 files
changed +212
−24
docs/design/areas/issues-list.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Issues list | |
| 2 | ||
| 3 | The list at rest (#8719): a status dot (`.dot`, `role=img`, one `aria-label`, no | |
| 4 | `title`), and for the states that need someone — working, look, stopped — the | |
| 5 | word written beside it in the row meta as `.word-text`, so colour is never the | |
| 6 | only mark and a phone without hover still reads it. One primary action ("New | |
| 7 | issue"). The repository's labels fold into a "Labels" disclosure on this list; | |
| 8 | every one is also a palette entry ("Issues labelled …"). |
src/web/studio/lists.ts +85 −19
| @@ -46,6 +46,7 @@ import { liveClientJs, liveMount } from "../live.ts"; | ||
| 46 | 46 | import { |
| 47 | 47 | avatar, |
| 48 | 48 | btn, |
| 49 | disclosure, | |
| 49 | 50 | empty, |
| 50 | 51 | icon, |
| 51 | 52 | labelChip, |
| @@ -67,6 +68,8 @@ import { | ||
| 67 | 68 | pullWords, |
| 68 | 69 | runWord, |
| 69 | 70 | wordBadge, |
| 71 | wordDot, | |
| 72 | wordText, | |
| 70 | 73 | type Word, |
| 71 | 74 | servingHistory, |
| 72 | 75 | type WordCtx, |
| @@ -231,7 +234,12 @@ function shown(word: Word, f: ListFacets): boolean { | ||
| 231 | 234 | |
| 232 | 235 | /** The narrowings in force as chips, each ✕ clearing itself, then the |
| 233 | 236 | * repository's labels to narrow by. Nothing at all when there is neither. */ |
| 234 | function facetChips(base: string, f: ListFacets, labels: readonly Label[]): Html { | |
| 237 | function facetChips( | |
| 238 | base: string, | |
| 239 | f: ListFacets, | |
| 240 | labels: readonly Label[], | |
| 241 | fold = false, | |
| 242 | ): Html { | |
| 235 | 243 | const active = ( |
| 236 | 244 | [ |
| 237 | 245 | ["assignee", "has it"], |
| @@ -254,6 +262,9 @@ function facetChips(base: string, f: ListFacets, labels: readonly Label[]): Html | ||
| 254 | 262 | : null; |
| 255 | 263 | if (active.length === 0 && labels.length === 0 && state === null) |
| 256 | 264 | return html``; |
| 265 | const chosen = f.label.toLowerCase(); | |
| 266 | const rest = labels.filter((l) => l.name.toLowerCase() !== chosen); | |
| 267 | const picked = labels.find((l) => l.name.toLowerCase() === chosen); | |
| 257 | 268 | return html`<div class="bar mb"> |
| 258 | 269 | ${state} |
| 259 | 270 | ${active.map( |
| @@ -266,17 +277,27 @@ function facetChips(base: string, f: ListFacets, labels: readonly Label[]): Html | ||
| 266 | 277 | >${f[name]} ${says} ✕</a |
| 267 | 278 | >`, |
| 268 | 279 | )} |
| 269 | ${labels.map((l) => | |
| 270 | f.label.toLowerCase() === l.name.toLowerCase() | |
| 271 | ? html`<a | |
| 272 | class="label lc-${l.color}" | |
| 273 | href="${facetHref(base, f, { label: "" })}" | |
| 274 | aria-current="true" | |
| 275 | title="Remove this filter" | |
| 276 | >${l.name} ✕</a | |
| 277 | >` | |
| 278 | : labelChip(l, facetHref(base, f, { label: l.name })), | |
| 280 | ${when( | |
| 281 | picked, | |
| 282 | (l) => html`<a | |
| 283 | class="label lc-${l.color}" | |
| 284 | href="${facetHref(base, f, { label: "" })}" | |
| 285 | aria-current="true" | |
| 286 | title="Remove this filter" | |
| 287 | >${l.name} ✕</a | |
| 288 | >`, | |
| 279 | 289 | )} |
| 290 | ${rest.length === 0 | |
| 291 | ? null | |
| 292 | : !fold | |
| 293 | ? rest.map((l) => labelChip(l, facetHref(base, f, { label: l.name }))) | |
| 294 | : disclosure( | |
| 295 | "Labels", | |
| 296 | html`<div class="bar"> | |
| 297 | ${rest.map((l) => labelChip(l, facetHref(base, f, { label: l.name })))} | |
| 298 | </div>`, | |
| 299 | { small: true }, | |
| 300 | )} | |
| 280 | 301 | </div>`; |
| 281 | 302 | } |
| 282 | 303 | |
| @@ -325,6 +346,39 @@ function listRead(ctx: Ctx, repo: Repo, f: ListFacets) { | ||
| 325 | 346 | return { options, record, wordCtx }; |
| 326 | 347 | } |
| 327 | 348 | |
| 349 | /** | |
| 350 | * Every action the issues list offers, as palette entries (Cmd-K): the | |
| 351 | * filters, the compose page and the outside reports. What the page shows | |
| 352 | * quietly is still one keystroke away. | |
| 353 | */ | |
| 354 | export function tellsListActions(o: { | |
| 355 | base: string; | |
| 356 | name: string; | |
| 357 | word: readonly WordFilter[]; | |
| 358 | signedIn: boolean; | |
| 359 | reports: number; | |
| 360 | labels?: readonly string[]; | |
| 361 | }): { t: string; h: string; c: string }[] { | |
| 362 | const c = "This list"; | |
| 363 | return [ | |
| 364 | { t: `${o.name} — Open issues`, h: `${o.base}/issues`, c }, | |
| 365 | ...o.word.map((w) => ({ | |
| 366 | t: `${o.name} — Issues: ${w === "told" ? "Not started" : w}`, | |
| 367 | h: `${o.base}/issues?word=${w}`, | |
| 368 | c, | |
| 369 | })), | |
| 370 | ...(o.labels ?? []).map((l) => ({ | |
| 371 | t: `${o.name} — Issues labelled ${l}`, | |
| 372 | h: `${o.base}/issues?label=${encodeURIComponent(l)}`, | |
| 373 | c, | |
| 374 | })), | |
| 375 | ...(o.signedIn ? [{ t: `${o.name} — New issue`, h: `${o.base}/issues/new`, c }] : []), | |
| 376 | ...(o.reports > 0 | |
| 377 | ? [{ t: `${o.name} — Reports from outside`, h: `${o.base}/reports`, c }] | |
| 378 | : []), | |
| 379 | ]; | |
| 380 | } | |
| 381 | ||
| 328 | 382 | /** |
| 329 | 383 | * GET /:owner/:name/issues: every issue, for every |
| 330 | 384 | * repository and every viewer — one row per issue in its studio word, who |
| @@ -392,16 +446,15 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 392 | 446 | row({ |
| 393 | 447 | href: tellHref(base, issue.number), |
| 394 | 448 | nav: true, |
| 395 | lead: avatar(issue.assigneeName ?? "crew"), | |
| 449 | lead: wordDot(info.word, info.note), | |
| 396 | 450 | title: titled(issue.title, labels.get(issue.id)), |
| 397 | 451 | titleAttr: issue.title, |
| 398 | meta: html`${when( | |
| 452 | meta: html`${wordText(info.word)}${when( | |
| 399 | 453 | issue.originAuthor, |
| 400 | 454 | (login) => html`${originTag(login, originGlyph(repo))} · `, |
| 401 | 455 | )}${issue.assigneeName |
| 402 | 456 | ? html`${issue.assigneeName} · ` |
| 403 | : null}${info.note} · ${time(issue.updatedAt)}`, | |
| 404 | trail: wordBadge(info.word), | |
| 457 | : null}${time(issue.updatedAt)}`, | |
| 405 | 458 | ...(mayLabel && issue.state === "open" |
| 406 | 459 | ? { |
| 407 | 460 | after: flowButton( |
| @@ -423,8 +476,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 423 | 476 | btn(PAGE_TITLES.compose, { |
| 424 | 477 | href: `${base}/issues/new`, |
| 425 | 478 | size: "sm", |
| 426 | variant: "outline", | |
| 427 | icon: "plus", | |
| 479 | variant: "primary", | |
| 428 | 480 | shortcut: "c", |
| 429 | 481 | }), |
| 430 | 482 | ); |
| @@ -441,7 +493,11 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 441 | 493 | const waiting = waitingCount(ctx.app, ctx.auth.user, repo); |
| 442 | 494 | const reports = |
| 443 | 495 | waiting > 0 |
| 444 | ? btn(`${waiting} from outside`, { href: `${base}/reports`, size: "sm" }) | |
| 496 | ? btn(`${waiting} from outside`, { | |
| 497 | href: `${base}/reports`, | |
| 498 | size: "sm", | |
| 499 | variant: "ghost", | |
| 500 | }) | |
| 445 | 501 | : undefined; |
| 446 | 502 | // Signed out, the way in: sign in to open one, or report a problem |
| 447 | 503 | // without an account where the repository is public. |
| @@ -479,7 +535,17 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 479 | 535 | ctx, |
| 480 | 536 | title: pageTitle("tells", repo, record !== undefined), |
| 481 | 537 | crumbs: crumbsFor(repo), |
| 482 | palette: repoPalette(ctx, repo), | |
| 538 | palette: [ | |
| 539 | ...repoPalette(ctx, repo), | |
| 540 | ...tellsListActions({ | |
| 541 | base, | |
| 542 | word: record === undefined ? PLAIN_FILTERS : WORD_FILTERS, | |
| 543 | name: `${repo.ownerName}/${repo.name}`, | |
| 544 | signedIn: ctx.auth.user !== undefined, | |
| 545 | reports: waiting, | |
| 546 | labels: repoLabels.map((l) => l.name), | |
| 547 | }), | |
| 548 | ], | |
| 483 | 549 | body, |
| 484 | 550 | styleCss: labelCss([...repoLabels, ...[...labels.values()].flat()]), |
| 485 | 551 | scriptJs: liveClientJs, |
src/web/studio/words.ts +27
| @@ -550,6 +550,33 @@ export function wordBadge(word: Word, id?: string): Html { | ||
| 550 | 550 | >`; |
| 551 | 551 | } |
| 552 | 552 | |
| 553 | /** A word at rest: one small coloured dot, the word (and its note) for a | |
| 554 | * screen reader. Colour alone is not a mark, so a state that needs someone | |
| 555 | * (`wordNeedsText`) is also written beside the row (see `wordText`). */ | |
| 556 | export function wordNeedsText(word: Word): boolean { | |
| 557 | return word === "working" || word === "look" || word === "stopped"; | |
| 558 | } | |
| 559 | ||
| 560 | /** The visible word for a state that needs someone; nothing for the rest. */ | |
| 561 | export function wordText(word: Word): Html | null { | |
| 562 | return wordNeedsText(word) | |
| 563 | ? html`<span class="word-text" data-word="${word}">${word}</span> · ` | |
| 564 | : null; | |
| 565 | } | |
| 566 | ||
| 567 | export function wordDot(word: Word, note?: string): Html { | |
| 568 | const tone = | |
| 569 | word === "look" || word === "shipped" || word === "done" | |
| 570 | ? "success" | |
| 571 | : word === "stopped" | |
| 572 | ? "danger" | |
| 573 | : word === "working" | |
| 574 | ? "attn" | |
| 575 | : "none"; | |
| 576 | const said = `${word === "told" ? "open" : word}${note ? ` — ${note}` : ""}`; | |
| 577 | return html`<span class="dot" data-tone="${tone}" data-word="${word}" role="img" aria-label="${said}"></span>`; | |
| 578 | } | |
| 579 | ||
| 553 | 580 | // ---- what a subscription has left, and what a change costs ---- |
| 554 | 581 | |
| 555 | 582 | /** One figure as a person reads it, and whether the forge worked it out |
src/web/style/components.ts +1
| @@ -235,6 +235,7 @@ progress::-webkit-progress-value { background: var(--success); } | ||
| 235 | 235 | progress::-moz-progress-bar { background: var(--success); } |
| 236 | 236 | |
| 237 | 237 | .dot { display: inline-block; width: 8px; height: 8px; border-radius: 999px; background: var(--faint); flex: none; } |
| 238 | .word-text { font-weight: 600; color: var(--fg); } | |
| 238 | 239 | .dot[data-tone="success"] { background: var(--success); } |
| 239 | 240 | .dot[data-tone="danger"] { background: var(--danger); } |
| 240 | 241 | .dot[data-tone="attn"] { background: var(--attn); } |
test/closed-issues-list-timing.test.ts +2 −2
| @@ -81,6 +81,6 @@ test("the closed list shows the newest page in order, each row worded by how it | ||
| 81 | 81 | // Issues are numbered with their pulls between them: the newest is 3000's. |
| 82 | 82 | expect(shown[0]).toBe(CLOSED); |
| 83 | 83 | // Odd ones were closed by hand, even ones by a merged pull. |
| 84 | expect(text.match(/closed as done/g)?.length).toBe(shown.filter((n) => n % 2 === 1).length); | |
| 85 | expect(text.match(/merged · /g)?.length).toBe(shown.filter((n) => n % 2 === 0).length); | |
| 84 | expect(text.match(/aria-label="[^"]*closed as done/g)?.length).toBe(shown.filter((n) => n % 2 === 1).length); | |
| 85 | expect(text.match(/aria-label="shipped — merged"/g)?.length).toBe(shown.filter((n) => n % 2 === 0).length); | |
| 86 | 86 | }); |
test/issues-quiet.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** The issues list at rest (#8719): a dot for status, one primary action, labels folded away — and every action still in the palette. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; | |
| 4 | import { tellsListActions } from "../src/web/studio/lists.ts"; | |
| 5 | import { wordText } from "../src/web/studio/words.ts"; | |
| 6 | ||
| 7 | let f: SnappyFixture; | |
| 8 | beforeAll(async () => { | |
| 9 | f = await snappyFixture(); | |
| 10 | }, 120_000); | |
| 11 | afterAll(async () => { | |
| 12 | await f.close(); | |
| 13 | }); | |
| 14 | ||
| 15 | // Measured 49,753 on the snappy fixture; the margin is rounding, not room. | |
| 16 | const BUDGET_BYTES = 50_000; | |
| 17 | ||
| 18 | test("a row wears a dot, not a word badge, and the page stays under its byte budget", async () => { | |
| 19 | const repo = f.repos[0]!; | |
| 20 | const res = await fetch(`${f.base}/${repo.ownerName}/${repo.name}/issues`, { | |
| 21 | headers: { cookie: f.ownerCookie }, | |
| 22 | }); | |
| 23 | const text = await res.text(); | |
| 24 | expect(res.status).toBe(200); | |
| 25 | expect(text).not.toMatch(/class="row-trail"[^>]*><span class="badge"/); | |
| 26 | // The dot says its word once, to a screen reader; no title repeats it. | |
| 27 | expect(text).toMatch(/class="dot"[^>]*aria-label="[^"]+"/); | |
| 28 | expect(text).not.toMatch(/class="dot"[^>]*title=/); | |
| 29 | expect(text).not.toContain("Status:"); | |
| 30 | expect(new TextEncoder().encode(text).length).toBeLessThan(BUDGET_BYTES); | |
| 31 | expect(text.match(/data-variant="primary"/g)?.length ?? 0).toBe(1); | |
| 32 | }); | |
| 33 | ||
| 34 | test("every action the list offers is in the palette", () => { | |
| 35 | const all = tellsListActions({ | |
| 36 | base: "/a/b", | |
| 37 | name: "a/b", | |
| 38 | word: ["told", "working", "look", "shipped", "stopped"], | |
| 39 | signedIn: true, | |
| 40 | reports: 2, | |
| 41 | }); | |
| 42 | const hrefs = all.map((i) => i.h); | |
| 43 | for (const h of [ | |
| 44 | "/a/b/issues", | |
| 45 | "/a/b/issues?word=told", | |
| 46 | "/a/b/issues?word=working", | |
| 47 | "/a/b/issues?word=look", | |
| 48 | "/a/b/issues?word=shipped", | |
| 49 | "/a/b/issues?word=stopped", | |
| 50 | "/a/b/issues/new", | |
| 51 | "/a/b/reports", | |
| 52 | ]) | |
| 53 | expect(hrefs).toContain(h); | |
| 54 | expect(tellsListActions({ base: "/a/b", name: "a/b", word: [], signedIn: false, reports: 0 }).map((i) => i.h)).toEqual(["/a/b/issues"]); | |
| 55 | }); | |
| 56 | ||
| 57 | test("the real page's palette carries the list's actions, and a state that needs someone is written out", async () => { | |
| 58 | const repo = f.repos[0]!; | |
| 59 | const base = `/${repo.ownerName}/${repo.name}`; | |
| 60 | const text = await ( | |
| 61 | await fetch(`${f.base}${base}/issues`, { headers: { cookie: f.ownerCookie } }) | |
| 62 | ).text(); | |
| 63 | for (const h of [`${base}/issues?word=working`, `${base}/issues?word=stopped`, `${base}/issues/new`]) | |
| 64 | expect(text).toContain(`"h":"${h}"`); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a label filter folded away is still in the palette", () => { | |
| 68 | const hrefs = tellsListActions({ | |
| 69 | base: "/a/b", | |
| 70 | name: "a/b", | |
| 71 | word: [], | |
| 72 | signedIn: false, | |
| 73 | reports: 0, | |
| 74 | labels: ["bug", "good first"], | |
| 75 | }).map((i) => i.h); | |
| 76 | expect(hrefs).toContain("/a/b/issues?label=bug"); | |
| 77 | expect(hrefs).toContain("/a/b/issues?label=good%20first"); | |
| 78 | }); | |
| 79 | ||
| 80 | test("working, look and stopped are written beside the dot; the quiet states are not", () => { | |
| 81 | expect(String(wordText("working"))).toContain("working"); | |
| 82 | expect(String(wordText("stopped"))).toContain("stopped"); | |
| 83 | expect(String(wordText("look"))).toContain("look"); | |
| 84 | expect(wordText("told")).toBeNull(); | |
| 85 | expect(wordText("shipped")).toBeNull(); | |
| 86 | }); |
test/studio-changes.test.ts +1 −1
| @@ -363,7 +363,7 @@ describe("the studio, continued", () => { | ||
| 363 | 363 | expect(issues).toContain("Plain list: an issue"); |
| 364 | 364 | // A plain repository speaks of issues: an untouched one reads "open", |
| 365 | 365 | // and neither Told nor Look is a filter there (#683 follow-up). |
| 366 | expect(issues).toMatch(/data-word="told"\s*>open</); | |
| 366 | expect(issues).toMatch(/data-word="told"[^>]*aria-label="open\b/); | |
| 367 | 367 | expect(issues).toContain(`href="/alice/plainlists/issues?word=working"`); |
| 368 | 368 | expect(issues).not.toContain(`href="/alice/plainlists/issues?word=told"`); |
| 369 | 369 | expect(issues).not.toContain(`href="/alice/plainlists/issues?word=look"`); |
test/web-ui.test.ts +2 −2
| @@ -820,7 +820,7 @@ describe("web ui", () => { | ||
| 820 | 820 | } |
| 821 | 821 | }); |
| 822 | 822 | |
| 823 | test("an issue row leads with who has it and trails its word", async () => { | |
| 823 | test("an issue row leads with its word as a dot", async () => { | |
| 824 | 824 | const html = await ( |
| 825 | 825 | await fetch(`${base}/mel/proj/issues`, { headers: { cookie: owner } }) |
| 826 | 826 | ).text(); |
| @@ -828,7 +828,7 @@ describe("web ui", () => { | ||
| 828 | 828 | expect(start).toBeGreaterThan(-1); |
| 829 | 829 | // From the lead to the end of the row it belongs to. |
| 830 | 830 | const row = html.slice(start, html.indexOf("</a", start)); |
| 831 | expect(row).toContain('class="avatar"'); | |
| 831 | expect(row).toContain('class="dot"'); | |
| 832 | 832 | expect(row).toContain('<span class="row-meta">'); |
| 833 | 833 | expect(row).toContain('data-word="told"'); |
| 834 | 834 | // The word is the row's state; no dev state badge repeats it. |
new above
offline