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

Merged opened this in answer to

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

Merged

scout/forge:scout/8719-quieter-pages-nothing-lost-a-pull-an-iss → main 3 commits fork

  • Merged. scout/8719-quieter-pages-nothing-lost-a-pull-an-iss was merged into main as 7802eb2.

Review

Sign in to review this change.
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";
4646 import {
4747 avatar,
4848 btn,
49 disclosure,
4950 empty,
5051 icon,
5152 labelChip,
@@ -67,6 +68,8 @@ import {
6768 pullWords,
6869 runWord,
6970 wordBadge,
71 wordDot,
72 wordText,
7073 type Word,
7174 servingHistory,
7275 type WordCtx,
@@ -231,7 +234,12 @@ function shown(word: Word, f: ListFacets): boolean {
231234
232235 /** The narrowings in force as chips, each ✕ clearing itself, then the
233236 * 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 {
235243 const active = (
236244 [
237245 ["assignee", "has it"],
@@ -254,6 +262,9 @@ function facetChips(base: string, f: ListFacets, labels: readonly Label[]): Html
254262 : null;
255263 if (active.length === 0 && labels.length === 0 && state === null)
256264 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);
257268 return html`<div class="bar mb">
258269 ${state}
259270 ${active.map(
@@ -266,17 +277,27 @@ function facetChips(base: string, f: ListFacets, labels: readonly Label[]): Html
266277 >${f[name]} ${says} ✕</a
267278 >`,
268279 )}
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 >`,
279289 )}
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 )}
280301 </div>`;
281302 }
282303
@@ -325,6 +346,39 @@ function listRead(ctx: Ctx, repo: Repo, f: ListFacets) {
325346 return { options, record, wordCtx };
326347 }
327348
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
328382 /**
329383 * GET /:owner/:name/issues: every issue, for every
330384 * 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> {
392446 row({
393447 href: tellHref(base, issue.number),
394448 nav: true,
395 lead: avatar(issue.assigneeName ?? "crew"),
449 lead: wordDot(info.word, info.note),
396450 title: titled(issue.title, labels.get(issue.id)),
397451 titleAttr: issue.title,
398 meta: html`${when(
452 meta: html`${wordText(info.word)}${when(
399453 issue.originAuthor,
400454 (login) => html`${originTag(login, originGlyph(repo))} · `,
401455 )}${issue.assigneeName
402456 ? html`${issue.assigneeName} · `
403 : null}${info.note} · ${time(issue.updatedAt)}`,
404 trail: wordBadge(info.word),
457 : null}${time(issue.updatedAt)}`,
405458 ...(mayLabel && issue.state === "open"
406459 ? {
407460 after: flowButton(
@@ -423,8 +476,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
423476 btn(PAGE_TITLES.compose, {
424477 href: `${base}/issues/new`,
425478 size: "sm",
426 variant: "outline",
427 icon: "plus",
479 variant: "primary",
428480 shortcut: "c",
429481 }),
430482 );
@@ -441,7 +493,11 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
441493 const waiting = waitingCount(ctx.app, ctx.auth.user, repo);
442494 const reports =
443495 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 })
445501 : undefined;
446502 // Signed out, the way in: sign in to open one, or report a problem
447503 // without an account where the repository is public.
@@ -479,7 +535,17 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
479535 ctx,
480536 title: pageTitle("tells", repo, record !== undefined),
481537 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 ],
483549 body,
484550 styleCss: labelCss([...repoLabels, ...[...labels.values()].flat()]),
485551 scriptJs: liveClientJs,
src/web/studio/words.ts +27
@@ -550,6 +550,33 @@ export function wordBadge(word: Word, id?: string): Html {
550550 >`;
551551 }
552552
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
553580 // ---- what a subscription has left, and what a change costs ----
554581
555582 /** 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); }
235235 progress::-moz-progress-bar { background: var(--success); }
236236
237237 .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); }
238239 .dot[data-tone="success"] { background: var(--success); }
239240 .dot[data-tone="danger"] { background: var(--danger); }
240241 .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
8181 // Issues are numbered with their pulls between them: the newest is 3000's.
8282 expect(shown[0]).toBe(CLOSED);
8383 // 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);
8686 });
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", () => {
363363 expect(issues).toContain("Plain list: an issue");
364364 // A plain repository speaks of issues: an untouched one reads "open",
365365 // 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/);
367367 expect(issues).toContain(`href="/alice/plainlists/issues?word=working"`);
368368 expect(issues).not.toContain(`href="/alice/plainlists/issues?word=told"`);
369369 expect(issues).not.toContain(`href="/alice/plainlists/issues?word=look"`);
test/web-ui.test.ts +2 −2
@@ -820,7 +820,7 @@ describe("web ui", () => {
820820 }
821821 });
822822
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 () => {
824824 const html = await (
825825 await fetch(`${base}/mel/proj/issues`, { headers: { cookie: owner } })
826826 ).text();
@@ -828,7 +828,7 @@ describe("web ui", () => {
828828 expect(start).toBeGreaterThan(-1);
829829 // From the lead to the end of the row it belongs to.
830830 const row = html.slice(start, html.indexOf("</a", start));
831 expect(row).toContain('class="avatar"');
831 expect(row).toContain('class="dot"');
832832 expect(row).toContain('<span class="row-meta">');
833833 expect(row).toContain('data-word="told"');
834834 // The word is the row's state; no dev state badge repeats it.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs