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

The pull page speaks the home page's language, with looks before and after #9659

Merged opened this

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

Merged

scout/forge:scout/9588-the-pull-page-speaks-the-home-page-s-lan → main 4 commits fork 7 threads · 7 unresolved

  • Merged. scout/9588-the-pull-page-speaks-the-home-page-s-lan was merged into main as ba44312.

Review

Sign in to review this change.
3 files changed +83 −15
docs/design/areas/pull.md +2
@@ -13,3 +13,5 @@ The terminal's pull screen (`src/tui/screens/pull.ts`) draws its own rows and sh
1313 On a 390 px phone the page does not scroll sideways (#9327): the title (`.page-head h1`) and every `.mono` (branch names) wrap with `overflow-wrap: anywhere`, comments already do through `.prose`. The diff scrolls inside its own box. No new class; `test/pull-phone-fit.test.ts` holds it.
1414
1515 `.split` (content.ts) has one `minmax(0, 1fr)` column below 960 px, so a long unbroken line in a comment cannot widen the page. It is global to every `.split` page; the `min-width: 960px` and `data-rail="wide"` rules come after it in the same file and still set their two columns, so desk layouts are unchanged.
16
17 The pull page's cards (merge box, review, commits, slices, run-this-code) are `.card.bare` (#9588): rows and a heading on the page's one background, no box. A bare card draws no tone, so the merge row carries none on the card: its state is the sentence's opening word ("Ready to merge", "Waiting on checks.", "Merged.", "Closed without merging.") with the tone's `.dot` before it, a word and a mark, not colour alone. The team verdicts are a `.list` of `.row`s, not a card. What stays a box: each file's diff (code scrolls inside its own frame) and the comments, which `thread.ts` draws for the issue page too; turning those into rows is the timeline's change, not this page's. No new class; `test/pull-merge-box.test.ts` holds it.
src/web/pages/pull.ts +20 −11
@@ -95,6 +95,7 @@ import {
9595 badge,
9696 btn,
9797 card,
98 dot,
9899 field as formField,
99100 icon,
100101 hintAttr,
@@ -431,7 +432,12 @@ function originCheckLine(ctx: Ctx, repo: Repo, pull: Pull): Html | "" {
431432 return html`<p class="muted">${originCheckLead(repo.origin.fullName, kept, pull.headOid)}, as of ${time(kept.checkedAt)}</p>`;
432433 }
433434
434 /** One §5 row in a toned card; `.setting-list` wraps the sentence and drops the action under it on a phone. */
435 /**
436 * One §5 row on a bare card; `.setting-list` wraps the sentence and drops the
437 * action under it on a phone. A bare card draws no tone, so the state is the
438 * sentence's opening word ("Merged.", "Ready to merge", "Waiting on checks.")
439 * with the tone's dot beside it: a word, not colour alone (principle 6).
440 */
435441 function box(
436442 tone: Tone | undefined,
437443 lead: Html,
@@ -440,16 +446,17 @@ function box(
440446 meta: Html,
441447 trail: Html | null,
442448 ): Html {
449 const said = typeof title === "string" ? raw(title) : title;
443450 return card(
444451 html`<ul class="list setting-list">
445452 ${row({
outdated

Principle 6: the tone is passed but a bare card draws no border, so merge state loses its only signal. Show it as a word/dot in the first row, and test it.

446453 lead,
447 title: typeof title === "string" ? raw(title) : title,
454 title: html`${dot(tone)} ${said}`,
448455 meta,
449456 ...(trail ? { trail } : {}),
450457 })}
451458 </ul>`,
outdated

Principle 1: the sentence already opens with the state ('Merged.', 'Blocked ...'), so the word says it twice. Drop the lead word from the sentences or reuse the sentence. Also state-word is a new class with no CSS; the area doc says 'No new class'. Use an existing element (plain text or .muted) or define it in docs/design/areas/pull.md and the stylesheet.

452 { ...(tone ? { tone } : {}), pad: false, extra: "mt" },
459 { pad: false, extra: "mt bare" },
453460 );
454461 }
455462
@@ -709,17 +716,17 @@ const VERDICT_WORDS: Record<string, string> = {
709716 /** One line per team the closed issue names: whose verdict it is, or that it is awaited (#2681). */
710717 export function teamsCard(verdicts: readonly TeamVerdict[]): Html {
711718 if (verdicts.length === 0) return html``;
712 return html`<section class="card team-verdicts" aria-label="Team reviews">
713 <ul>
719 return html`<section class="team-verdicts mt" aria-label="Team reviews">
720 <ul class="list">
714721 ${verdicts.map(
715 (v) => html`<li>
722 (v) => html`<li class="row"><span>
716723 <strong>${v.name}</strong>:
outdated

Principle 7: a plain list is right, but .team-verdicts has no style anywhere in src; confirm the rows keep their rhythm without .card (look at 390) and that the mt spacing is not doubled.

717724 ${v.state === undefined
718725 ? html`<span class="muted">awaiting a reviewer</span>`
719726 : html`${VERDICT_WORDS[v.state] ?? v.state} by ${v.by ?? ""}${v.stale
720727 ? html` <span class="muted">(on an older head)</span>`
721728 : ""}`}
722 </li>`,
729 </span></li>`,
723730 )}
724731 </ul>
725732 </section>`;
@@ -744,7 +751,7 @@ function reviewCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html {
744751 const user = ctx.auth.user;
745752 if (user === undefined)
746753 return card(html`<a href="/signin">Sign in</a> to review this change.`, {
747 extra: "mt",
754 extra: "mt bare",
748755 head: html`${icon("eye")}
749756 <h2>Review</h2>`,
750757 });
@@ -829,7 +836,7 @@ function reviewCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html {
829836 </div>
830837 </form>`,
831838 {
832 extra: "mt review-card",
839 extra: "mt review-card bare",
833840 head: html`${icon("eye")}
834841 <h2>Review</h2>`,
835842 },
@@ -1011,6 +1018,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
10111018 )}
10121019 </ul>`,
10131020 {
1021 extra: "bare",
10141022 pad: false,
10151023 head: html`${icon("commit")}
10161024 <h2>${commits.length} commit${commits.length === 1 ? "" : "s"}</h2>`,
@@ -1279,7 +1287,7 @@ export function approveHereCard(
12791287 ${btn(proposed ? "Let it run here" : "Approve to run here", { size: "sm", icon: "check" })}
12801288 </form>`,
12811289 {
1282 extra: "mb",
1290 extra: "mb bare",
12831291 head: html`${icon("eye")}
12841292 <h2>Run this code?</h2>`,
12851293 },
@@ -1506,7 +1514,7 @@ export async function pullSliceSubmit(ctx: Ctx): Promise<Response> {
15061514 * left, and — for a writer — a form to land the author's commits up to one
15071515 * of them as their own merge commit.
15081516 */
1509 function slicesCard(
1517 export function slicesCard(
15101518 base: string,
15111519 thread: string,
15121520 slices: readonly LandedSlice[],
@@ -1553,6 +1561,7 @@ function slicesCard(
15531561 </form>`,
15541562 )}`,
15551563 {
1564 extra: "bare",
15561565 head: html`${icon("merge")}
15571566 <h2>Slices</h2>`,
15581567 },
test/pull-merge-box.test.ts +61 −4
@@ -12,6 +12,8 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1212 import { createSession } from "../src/core/sessions.ts";
1313 import { createUser, type User } from "../src/core/users.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
15 import type { TeamVerdict } from "../src/core/issue_teams.ts";
16 import { teamsCard } from "../src/web/pages/pull.ts";
1517 import { quietLogs } from "./helpers/globals.ts";
1618
1719 quietLogs();
@@ -23,14 +25,26 @@ let alice: User;
2325 const numbers: Record<string, number> = {};
2426 const cells = process.env["FORGE_CELLS"];
2527
26 const boxOf = async (repo: string): Promise<string> => {
28 const pageOf = async (repo: string): Promise<string> => {
2729 const res = await fetch(
2830 `${servers.url}/alice/${repo}/pulls/${numbers[repo]}/files`,
2931 { headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` } },
3032 );
31 const body = await res.text();
33 return res.text();
outdated

A selector rename only; nothing here would fail without the change. Add a test asserting bare on each converted card.

34 };
35
36 /** The class list of the card whose head names `heading`. */
37 const cardClassOf = (body: string, heading: string): string => {
38 const at = body.indexOf(`<h2>${heading}`);
39 expect(at).toBeGreaterThan(-1);
40 const before = [...body.slice(0, at).matchAll(/class="(card(?: [^"]*)?)"/g)];
41 return before.at(-1)?.[1] ?? "";
42 };
43
44 const boxOf = async (repo: string): Promise<string> => {
45 const body = await pageOf(repo);
3246 expect(body).not.toMatch(/\bmerge-(?:box|form|headline|sub)\b/);
33 const at = body.search(/<div\s+class="card mt"[^>]*data-tone=/);
47 const at = body.search(/<div\s+class="card mt bare"\s*>\s*<ul class="list setting-list">/);
3448 expect(at).toBeGreaterThan(-1);
3549 return body.slice(at, body.indexOf("</ul>", at)).replace(/\s+/g, " ");
3650 };
@@ -50,7 +64,7 @@ beforeAll(async () => {
5064 });
5165 if (!made.ok) throw new Error(String(made.error));
5266 alice = made.value;
53 for (const name of ["ready", "checks", "merged"]) {
67 for (const name of ["ready", "checks", "merged", "closed", "sliced", "fork"]) {
5468 const repo = await createRepo(app, alice, { name, visibility: "public" });
5569 if (!repo.ok) throw new Error(String(repo.error));
5670 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
@@ -61,6 +75,14 @@ beforeAll(async () => {
6175 });
6276 if (!done.ok) throw new Error(JSON.stringify(done.error));
6377 }
78 if (name === "sliced") {
79 const more = await commitToBranch(app, alice, repo.value, {
80 branch: "topic",
81 message: "a second change",
82 files: [{ path: "u.txt", content: "y" }],
83 });
84 if (!more.ok) throw new Error(JSON.stringify(more.error));
85 }
6486 const pull = await openPull(app, alice, repo.value, {
6587 title: name,
6688 body: "",
@@ -71,6 +93,10 @@ beforeAll(async () => {
7193 numbers[name] = pull.value.number;
7294 if (name === "checks")
7395 app.db.run("UPDATE repo SET require_checks = 1 WHERE id = ?", [repo.value.id]);
96 if (name === "closed")
97 app.db.run("UPDATE issue SET state = 'closed' WHERE id = ?", [pull.value.id]);
98 if (name === "fork")
99 app.db.run("UPDATE pull SET origin_head_repo = 'bob/fork' WHERE issue_id = ?", [pull.value.id]);
74100 if (name === "merged") {
75101 const done = await mergePull(app, alice, repo.value, pull.value, "merge");
76102 if (!done.ok) throw new Error(JSON.stringify(done.error));
@@ -114,3 +140,34 @@ test("merged: the merged tone and the sentence", async () => {
114140 expect(box).toContain("Merged.");
115141 expect(box).toContain("was merged into");
116142 });
143
144
145 test("the merge state is the sentence's opening word with its dot, the card bare (principle 6)", async () => {
146 expect(await boxOf("ready")).toMatch(/class="dot" data-tone="success" ><\/span> Ready to merge/);
147 expect(await boxOf("checks")).toMatch(/data-tone="attn" ><\/span> Waiting on checks\./);
148 expect(await boxOf("merged")).toMatch(/data-tone="merged" ><\/span> Merged\./);
149 // Closed has no tone: a plain dot, and the word still says it.
150 expect(await boxOf("closed")).toMatch(/class="dot" ><\/span> Closed without merging\./);
151 expect(await boxOf("fork")).toMatch(/data-tone="attn" ><\/span> Merge on the origin\./);
152 });
153
154 test("the review, commits, slices and run-this-code cards are bare", async () => {
155 const sliced = await pageOf("sliced");
156 expect(cardClassOf(sliced, "Review")).toMatch(/\bbare\b/);
157 expect(cardClassOf(sliced, "2 commits")).toMatch(/\bbare\b/);
158 expect(cardClassOf(sliced, "Slices")).toMatch(/\bbare\b/);
159 expect(cardClassOf(await pageOf("fork"), "Run this code?")).toMatch(/\bbare\b/);
160 });
161
162 test("the team verdicts are a plain list of rows, not a card (principle 7)", () => {
163 const verdicts: TeamVerdict[] = [
164 { team: "core", name: "Core", state: "approved", by: "bob", stale: false },
165 { team: "docs", name: "Docs", state: undefined, by: undefined, stale: false },
166 ];
167 const out = String(teamsCard(verdicts));
168 expect(out).toContain('class="team-verdicts mt"');
169 expect(out).toContain('<ul class="list">');
170 expect(out).not.toMatch(/class="[^"]*\bcard\b/);
171 expect(out).toContain("approved by bob");
172 expect(out).toContain("awaiting a reviewer");
173 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs