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

The commits list and a commit's page speak the home page's language #9778

Merged opened this

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

Merged

scout/forge:scout/9742-the-commits-list-and-a-commit-s-page-spe → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/9742-the-commits-list-and-a-commit-s-page-spe was merged into main as b33d536.

Review

Sign in to review this change.
3 files changed +79 −89
src/web/pages/commits.ts +49 −80
@@ -30,10 +30,7 @@ import { localScope, type RepoScope } from "../scope.ts";
3030 import {
3131 avatar,
3232 badge,
33 card,
3433 empty,
35 icon,
36 listCard,
3734 messageText,
3835 originTag,
3936 pager,
@@ -59,8 +56,7 @@ const PER_PAGE = 30;
5956 */
6057 export const COMMITS_CSS = `[data-commits] { overflow-wrap: anywhere; }
6158 [data-commits] .row-title, [data-commits] .row-meta { white-space: normal; overflow: visible; text-overflow: clip; }
62 [data-commits] .row { align-items: flex-start; }
63 [data-commits] .card-head h2 { min-width: 0; }`;
59 [data-commits] .row { align-items: flex-start; }`;
6460
6561 /** Infinity, 1e300 and NaN all collapse to page 1 — never a bogus skip. */
outdated

Principles 3 and 6: a hand-rolled border-top on .list in page CSS. If the list needs a hairline, it belongs to the named list component, not an override in this page. Drop it and let space separate the list from the heading.

6662 function pageNumber(raw: string | null): number {
outdated

Principles 4 and 8 / tokens: a literal 1.25rem font-size in page CSS. Use the type token the home page uses for a heading instead of a private size, or no rule at all.

@@ -82,58 +78,38 @@ export function commitRows(
8278 commits: readonly Commit[],
8379 verdicts: ReadonlyMap<string, CommitCheck> = new Map(),
8480 ): Html {
85 const glyph = scope.origin?.kind ?? scope.repo?.origin?.kind ?? "globe";
86 const lead = (commit: Commit): Html =>
87 html`<span class="row-lead"
88 >${scope.kind === "origin" && commit.login
89 ? icon(glyph)
90 : avatar(commit.author)}</span
91 >`;
92 const text = (commit: Commit): Html =>
93 html`<span class="row-title" title="${commit.subject}">${commit.subject}</span>
94 <span class="row-meta"
95 >${commit.author} committed
96 ${time(commit.date)}${when(
97 commit.parents.length > 1,
98 () => html` · ${badge("merge", { variant: "secondary" })}`,
99 )}</span
100 >`;
101 return html`<div data-commits>${listCard(
81 return html`<div data-commits><ul class="list">${
10282 commits.map((commit) => {
10383 const verdict = verdicts.get(commit.oid);
104 const step = verdict?.summary.split("\n")[0]?.trim() ?? "";
105 // A link cannot sit inside a link: a row with a verdict is a row whose
106 // title links to the commit and whose badge links to the check.
107 if (verdict === undefined || verdict.runId === null)
108 return html`<li>
109 <a
110 class="row"
111 data-nav
112 tabindex="-1"
113 href="${scope.base}/commit/${commit.oid}"
114 >
115 ${lead(commit)}<span class="row-main">${text(commit)}</span>
116 <span class="row-trail">${sha(commit.oid)}</span>
117 </a>
118 </li>`;
84 // The message is the row's one link; the check is a plain badge here and
85 // its run is reachable from the commit page.
11986 return html`<li>
120 <div class="row" data-nav tabindex="-1">
121 ${lead(commit)}
122 <a class="row-main" href="${scope.base}/commit/${commit.oid}"
123 >${text(commit)}</a
87 <a
88 class="row"
89 data-nav
90 tabindex="-1"
91 href="${scope.base}/commit/${commit.oid}"
92 ><span class="row-main"
93 ><span class="row-title" title="${commit.subject}">${commit.subject}</span>
94 <span class="row-meta"
95 >${commit.author} committed
96 ${time(commit.date)}${when(
97 commit.parents.length > 1,
98 () => html` · ${badge("merge", { variant: "secondary" })}`,
99 )}</span
100 ></span
outdated

Principle 7 and box 1: the rows keep lead(commit) (avatar/origin icon) and a second link (the check badge) beside the message link, so the message is not the one link and the row is not the home page row. Make the message the only link; show the check as plain text/badge (non-link) with the run reachable from the commit page. Also drop the avatar from the row; author goes in the muted meta line. Add a test that the list has no card markup and that each row has exactly one anchor.

124101 >
125102 <span class="row-trail"
126 ><a
127 href="${scope.base}/runs/${verdict.runId}"
128 title="${step}"
129 aria-label="Check ${verdict.state === "ok" ? "passed" : "failed"} for ${commit.oid.slice(0, 7)}${step === "" ? "" : `: ${step}`}"
130 >${runStateBadge(verdict.state === "ok" ? "ok" : "failed", "check")}</a
131 >${sha(commit.oid)}</span
103 >${when(
104 verdict !== undefined && verdict.runId !== null,
105 () =>
106 html`${runStateBadge(verdict!.state === "ok" ? "ok" : "failed", "check")} `,
107 )}${sha(commit.oid)}</span
132108 >
133 </div>
109 </a>
134110 </li>`;
135 }),
136 )}</div>`;
111 })
112 }</ul></div>`;
137113 }
138114
139115 /** A commit's check in a sentence; on red, the first failing step as the check named it. */
@@ -174,38 +150,31 @@ export function renderCommit(
174150 ): Html {
175151 const verdict: Html =
176152 check !== null && check.state !== "none"
177 ? html`<div class="card-body small">${checkSentence(scope, check)}</div>`
153 ? html`<p class="small">${checkSentence(scope, check)}</p>`
178154 : html``;
179 const head: Html = card(
180 html`${verdict}${when(
181 commit.body.trim() !== "",
182 () => html`<div class="card-body">${messageText(commit.body)}</div>`,
155 const head: Html = html`<h2>${commit.subject}</h2>
156 ${verdict}
157 ${when(
158 commit.body.trim() !== "",
159 () => messageText(commit.body),
160 )}
161 <p class="bar small muted">
162 ${commit.login
163 ? originTag(
164 commit.login,
165 scope.origin?.kind ?? scope.repo?.origin?.kind ?? "globe",
166 )
167 : avatar(commit.author)}
168 <span>${commit.author} committed ${time(commit.date)}</span>
169 ${sha(commit.oid)}
170 ${when(
171 commit.parents.length > 1,
172 () => html`${badge("merge", { icon: "merge" })}`,
183173 )}
184 <div class="card-body bar small muted">
185 ${commit.login
186 ? originTag(
187 commit.login,
188 scope.origin?.kind ?? scope.repo?.origin?.kind ?? "globe",
189 )
190 : avatar(commit.author)}
191 <span>${commit.author} committed ${time(commit.date)}</span>
192 ${sha(commit.oid)}
193 ${when(
194 commit.parents.length > 1,
195 () => html`${badge("merge", { icon: "merge" })}`,
196 )}
197 <span class="spacer"></span>
198 <a
199 class="btn"
200 data-size="sm"
201 data-variant="outline"
202 href="${scope.base}/tree/${commit.oid}"
203 >${icon("repo")}Browse files</a
204 >
205 </div>`,
206 { head: html`<h2>${commit.subject}</h2>`, pad: false },
207 );
208 return html`<div data-commits><div class="mt">${head}</div></div>
174 <span class="spacer"></span>
175 <a href="${scope.base}/tree/${commit.oid}">Browse files</a>
176 </p>`;
177 return html`<div data-commits>${head}</div>
209178 <div class="mt">
outdated

Principle 1: the check sentence was the first thing on the commit page and is now last, below the body and meta, so a red commit is easy to miss (box 2: nothing lost). Put the check line directly under the subject. Also add a test asserting the commit page has no card and keeps its check sentence for green, red and not-run.

210179 ${renderDiff(diff, {
211180 syntax,
test/commit-page-timing.test.ts +3 −2
@@ -104,11 +104,12 @@ test("a commit's head draws §5's bar, not .issue-meta", async () => {
104104 expect(body).toContain(oid.slice(0, 7));
105105 });
106106
107 test("a commit's head and body draw §5's card-head and prose, not commit-head/commit-body", async () => {
107 test("a commit's head and body draw a heading and prose, not commit-head/commit-body", async () => {
108108 const body = await (await fetch(`${servers.url}/ada/engine/commit/${oid}`)).text();
109109 expect(body).not.toContain("commit-head");
110110 expect(body).not.toContain("commit-body");
111 expect(body).toContain('<div class="card-head"><h2>add a</h2></div>');
111 expect(body).toContain("<h2>add a</h2>");
112 expect(body).not.toContain("card-head");
112113 });
113114
114115 test("a commit with a body shows it, escaped, inside the prose message div", async () => {
test/web-commits-check.test.ts +27 −7
@@ -84,15 +84,15 @@ const get = async (path: string, cookie?: string) =>
8484 const rowOf = (page: string, subject: string): string =>
8585 (page.split("<li>").find((l) => l.includes(subject)) ?? "").split("</li>")[0]!;
8686
87 test("the list shows green and red linked to their runs, and nothing without a check", async () => {
87 test("the list shows green and red as plain badges, and nothing without a check", async () => {
8888 const page = await get("/alice/pub/commits");
8989 const green = rowOf(page, "green change");
9090 const red = rowOf(page, "red change");
9191 const none = rowOf(page, "none change");
9292 expect(green).toContain("Passed");
93 expect(green).toContain(`href="/alice/pub/runs/${runs.green}"`);
9493 expect(red).toContain("Failed");
95 expect(red).toContain(`href="/alice/pub/runs/${runs.red}"`);
94 expect(green).not.toContain("/runs/");
95 expect(red).not.toContain("/runs/");
9696 expect(none).not.toContain("/runs/");
9797 expect(none).not.toContain("badge");
9898 });
@@ -125,7 +125,6 @@ test("a private repository's verdict is the owner's alone", async () => {
125125 expect(seen).not.toContain("step secret failed");
126126 }
127127 const mine = await get(list, ownerCookie);
128 expect(mine).toContain(`href="/alice/priv/runs/${runs.priv}"`);
129128 expect(mine).toContain("Failed");
130129 expect(await get(one, ownerCookie)).toContain("first failing step secret failed");
131130 });
@@ -153,7 +152,28 @@ test("the sentence names whose check answered, a running check and a superseded
153152 expect(gone).not.toContain("/runs/");
154153 });
155154
156 test("the badge link names the commit and the failing step", async () => {
157 const red = rowOf(await get("/alice/pub/commits"), "red change");
158 expect(red).toContain(`aria-label="Check failed for ${oids["pub/red"]!.slice(0, 7)}: step typecheck failed"`);
155 test("the list has no card markup and each row has exactly one anchor and no avatar", async () => {
156 const page = await get("/alice/pub/commits");
157 expect(page).not.toContain('class="card');
158 const rows = page.split("<li>").slice(1).map((l) => l.split("</li>")[0]!).filter((l) => l.includes('class="row"'));
159 expect(rows.length).toBe(3);
160 for (const row of rows) {
161 expect(row.match(/<a[\s>]/g)?.length).toBe(1);
162 expect(row).not.toContain("avatar");
163 expect(row).not.toContain("row-lead");
164 }
165 });
166
167 test("a commit's page has no card and its check line follows the subject for green, red and not-run", async () => {
168 for (const [n, said] of [["green", "The check passed"], ["red", "The check failed"], ["none", ""]] as const) {
169 const one = await get(`/alice/pub/commit/${oids[`pub/${n}`]}`);
170 expect(one).not.toContain('class="card');
171 const afterSubject = one.slice(one.indexOf(`${n} change</h2>`));
172 if (said === "") expect(one).not.toContain("The check");
173 else {
174 const at = afterSubject.indexOf(said);
175 expect(at).toBeGreaterThan(0);
176 expect(at).toBeLessThan(afterSubject.indexOf("committed"));
177 }
178 }
159179 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs