Public: anyone can read this; the crew can change it.
The milestones page speaks the home page's language #9698
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9693-the-milestones-page-speaks-the-home-page → main 3 commits fork 2 threads · 2 unresolved
6 files
changed +59
−45
docs/design/areas/milestones.md +9
| @@ -16,3 +16,12 @@ unbroken word as the longest title that can exist; the estimate and trim notes | ||
| 16 | 16 | are built from titles and counts, so no field takes 300. It is an attribute |
| 17 | 17 | because the area adds no component class. Every preview of the forge takes |
| 18 | 18 | `/preview-admin/sample/milestones` at desk and phone (`PREVIEW_OWNER_LOOKS`). |
| 19 | ||
| 20 | ## Rows under headings, not boxes (#9693) | |
| 21 | ||
| 22 | The milestone list, and every section of an opened milestone (limbs, building | |
| 23 | now, blocked, cost, merged, issues, re-planned), is a heading over plain rows: | |
| 24 | a `ul.list.setting-list` with no `.card` around it, and a folded tail is a | |
| 25 | `details` under the rows, not a card foot. How far it is stands as a plain | |
| 26 | `#ms-progress` stack. No new component class; `limb.ts` and the re-plan page | |
| 27 | share the same rows. | |
src/web/pages/limb_view.ts +9 −12
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { html, type Html } from "../html.ts"; |
| 7 | import { card, row } from "../ui.ts"; | |
| 7 | import { row } from "../ui.ts"; | |
| 8 | 8 | |
| 9 | 9 | /** An issue as a row: its title, its number, and whatever is known of it. */ |
| 10 | 10 | export function issueRow( |
| @@ -25,18 +25,15 @@ export function issueRow( | ||
| 25 | 25 | export const list = (rows: Html[], wrap = false): Html => |
| 26 | 26 | html`<ul class="list${wrap ? " setting-list" : ""}">${rows}</ul>`; |
| 27 | 27 | |
| 28 | /** A card of rows, wrapping. */ | |
| 29 | export const wrappedCard = (rows: Html[]): Html => | |
| 30 | card(list(rows, true), { pad: false }); | |
| 28 | /** Wrapping rows under a heading: no box around them. */ | |
| 29 | export const wrappedRows = (rows: Html[]): Html => list(rows, true); | |
| 31 | 30 | |
| 32 | /** A card of wrapping rows that shows its first `shown` and folds the rest: a long title wraps on a phone rather than running past its edge. */ | |
| 31 | /** Wrapping rows that show the first `shown` and fold the rest: a long title wraps on a phone rather than running past its edge. */ | |
| 33 | 32 | export function folded(rows: Html[], what: string, shown: number): Html { |
| 34 | if (rows.length <= shown) return wrappedCard(rows); | |
| 35 | return card(list(rows.slice(0, shown), true), { | |
| 36 | pad: false, | |
| 37 | foot: html`<details> | |
| 38 | <summary class="small">${rows.length - shown} more ${what}</summary> | |
| 33 | if (rows.length <= shown) return wrappedRows(rows); | |
| 34 | return html`${list(rows.slice(0, shown), true)} | |
| 35 | <details> | |
| 36 | <summary class="small muted">${rows.length - shown} more ${what}</summary> | |
| 39 | 37 | ${list(rows.slice(shown), true)} |
| 40 | </details>`, | |
| 41 | }); | |
| 38 | </details>`; | |
| 42 | 39 | } |
src/web/pages/milestones.ts +16 −22
| @@ -59,8 +59,8 @@ import { html, when, type Html } from "../html.ts"; | ||
| 59 | 59 | import { field, readForm } from "../form.ts"; |
| 60 | 60 | import { page, redirect } from "../layout.ts"; |
| 61 | 61 | import { liveClientJs, liveMount } from "../live.ts"; |
| 62 | import { badge, btn, card, pill, row, time } from "../ui.ts"; | |
| 63 | import { folded, issueRow, list, wrappedCard } from "./limb_view.ts"; | |
| 62 | import { badge, btn, pill, row, time } from "../ui.ts"; | |
| 63 | import { folded, issueRow, list, wrappedRows } from "./limb_view.ts"; | |
| 64 | 64 | |
| 65 | 65 | /** Where a milestone is: its own page. */ |
| 66 | 66 | export const milestoneHref = milestonePath; |
| @@ -244,7 +244,7 @@ export function milestonesPage(ctx: Ctx): Response { | ||
| 244 | 244 | show. <a href="${base}/map">See the map</a>. |
| 245 | 245 | </p>` |
| 246 | 246 | : html`<div id="ms-limbs"> |
| 247 | ${wrappedCard( | |
| 247 | ${wrappedRows( | |
| 248 | 248 | plan.limbs.map((limb) => |
| 249 | 249 | limbLine(base, limb, move?.(limb.issue, "top") ?? null), |
| 250 | 250 | ), |
| @@ -325,7 +325,7 @@ function replanChanged(ctx: Ctx, repo: Repo, m: Milestone): Html | null { | ||
| 325 | 325 | <span class="muted small">#${n.number}</span>`; |
| 326 | 326 | return html`<section class="mt" id="ms-replanned"> |
| 327 | 327 | <h2 class="mb">Re-planned ${time(result.at)}</h2> |
| 328 | ${wrappedCard( | |
| 328 | ${wrappedRows( | |
| 329 | 329 | result.splits.map((s) => |
| 330 | 330 | row({ |
| 331 | 331 | title: link(s), |
| @@ -409,14 +409,11 @@ function milestoneBody( | ||
| 409 | 409 | <a href="${base}/milestones">Milestones</a> |
| 410 | 410 | </p> |
| 411 | 411 | <h1>${m.name}</h1> |
| 412 | ${card( | |
| 413 | html`<div class="stack stack-tight"> | |
| 414 | <span><b>${w.done}</b> of ${w.total} done</span> | |
| 415 | ${progress(w.done, w.total, `${w.done} of ${w.total} done`)} | |
| 416 | <span class="muted small">${weightWords(m)}</span> | |
| 417 | </div>`, | |
| 418 | m.trim === undefined ? {} : { tone: "attn" }, | |
| 419 | )} | |
| 412 | <div class="stack stack-tight" id="ms-progress"> | |
| 413 | <span><b>${w.done}</b> of ${w.total} done</span> | |
| 414 | ${progress(w.done, w.total, `${w.done} of ${w.total} done`)} | |
| 415 | <span class="muted small">${weightWords(m)}</span> | |
| 416 | </div> | |
| 420 | 417 | <p id="ms-estimate">${m.estimate}</p> |
| 421 | 418 | ${paceLine(m.pace)} |
Principle 8/3: the progress stack dropped the card, which is right, but the |
||
| 422 | 419 | ${when( |
| @@ -435,7 +432,7 @@ function milestoneBody( | ||
| 435 | 432 | () => |
| 436 | 433 | html`<section class="mt" id="ms-limbs"> |
| 437 | 434 | <h2 class="mb">Limbs</h2> |
| 438 | ${wrappedCard( | |
| 435 | ${wrappedRows( | |
| 439 | 436 | m.limbs.map((limb) => |
| 440 | 437 | limbLine(base, limb, move?.(limb.issue, String(m.issue.number)) ?? null), |
| 441 | 438 | ), |
| @@ -447,7 +444,7 @@ function milestoneBody( | ||
| 447 | 444 | () => |
| 448 | 445 | html`<section class="mt" id="ms-building"> |
| 449 | 446 | <h2 class="mb">Building now</h2> |
| 450 | ${wrappedCard( | |
| 447 | ${wrappedRows( | |
| 451 | 448 | m.building.map((b) => |
| 452 | 449 | issueRow( |
| 453 | 450 | base, |
| @@ -499,7 +496,7 @@ function milestoneBody( | ||
| 499 | 496 | </p> |
| 500 | 497 | ${spend.costliest.length === 0 |
| 501 | 498 | ? html`<p class="muted small">Nothing has cost anything yet.</p>` |
| 502 | : wrappedCard( | |
| 499 | : wrappedRows( | |
| 503 | 500 | spend.costliest.map((c) => |
| 504 | 501 | issueRow(base, c.issue, undefined, pill(dollars(c.usd))), |
| 505 | 502 | ), |
| @@ -540,13 +537,10 @@ function milestoneBody( | ||
| 540 | 537 | ${when( |
| 541 | 538 | closed.length > 0, |
| 542 | 539 | () => |
| 543 | card( | |
| 544 | html`<details> | |
| 545 | <summary class="small">${closed.length} closed</summary> | |
| 546 | ${list(closed.map((leaf) => leafRow(leaf, false)), true)} | |
| 547 | </details>`, | |
| 548 | { pad: false }, | |
| 549 | ), | |
| 540 | html`<details> | |
| 541 | <summary class="small muted">${closed.length} closed</summary> | |
| 542 | ${list(closed.map((leaf) => leafRow(leaf, false)), true)} | |
| 543 | </details>`, | |
| 550 | 544 | )} |
| 551 | 545 | </section>`, |
| 552 | 546 | )}`; |
test/fixtures/milestones-golden.html +1 −10
| @@ -6,14 +6,7 @@ | ||
| 6 | 6 | </p> |
| 7 | 7 | </div> |
| 8 | 8 | <div id="ms-limbs"> |
| 9 | <div | |
| 10 | class="card" | |
| 11 | ||
| 12 | ||
| 13 | ||
| 14 | > | |
| 15 | ||
| 16 | <ul class="list setting-list"><li> | |
| 9 | <ul class="list setting-list"><li> | |
| 17 | 10 | <div class="row" |
| 18 | 11 | |
| 19 | 12 | > |
| @@ -990,6 +983,4 @@ | ||
| 990 | 983 | ></span |
| 991 | 984 | ></span></div> |
| 992 | 985 | </li></ul> |
| 993 | ||
| 994 | </div> | |
| 995 | 986 | </div>↵ |
test/flows-all.test.ts +2 −1
| @@ -20,7 +20,8 @@ quietLogs(); | ||
| 20 | 20 | let root = ""; |
| 21 | 21 | let app: App; |
| 22 | 22 | let servers: Servers; |
| 23 | const NOW = Date.parse("2026-11-08T12:00:00Z"); | |
| 23 | // The page reads the real clock, so the seed is offsets from it. | |
| 24 | const NOW = Date.now(); | |
| 24 | 25 | |
| 25 | 26 | beforeAll(async () => { |
| 26 | 27 | root = mkdtempSync(join(tmpdir(), "forge-flows-all-")); |
test/milestones-golden.test.ts +22
| @@ -93,3 +93,25 @@ test("a guest is told nothing of a private repository's milestones", async () => | ||
| 93 | 93 | expect(res.status).toBe(404); |
| 94 | 94 | expect(await res.text()).not.toContain("Milestone 0000"); |
| 95 | 95 | }); |
| 96 | ||
| 97 | test("the list and an opened milestone are headings over rows, with no card box around them (#9693)", async () => { | |
| 98 | const signin = await fetch(`${servers.url}/signin`, { | |
| 99 | method: "POST", | |
| 100 | headers: { origin: servers.url }, | |
| 101 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 102 | redirect: "manual", | |
| 103 | }); | |
| 104 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 105 | const list = await (await fetch(`${servers.url}/alice/forge/milestones`, { headers: { cookie } })).text(); | |
| 106 | const main = list.slice(list.indexOf('id="ms-limbs"')); | |
| 107 | expect(main).toContain('<ul class="list setting-list">'); | |
| 108 | const section = main.slice(0, main.search(/\n\s*<div\s+id="live"/)); | |
| 109 | expect(section).not.toContain('class="card'); | |
| 110 | const href = /href="(\/alice\/forge\/milestones\/\d+)"/.exec(list)?.[1]; | |
| 111 | expect(href).toBeDefined(); | |
| 112 | const one = await (await fetch(`${servers.url}${href}`, { headers: { cookie } })).text(); | |
| 113 | const body = one.slice(one.indexOf("<h1>"), one.search(/\n\s*<div\s+id="live"/)); | |
| 114 | expect(body).not.toMatch(/class="card[ "]/); | |
| 115 | expect(body).toContain('id="ms-progress"'); | |
| 116 | expect(body).toContain('<ul class="list setting-list">'); | |
| 117 | }); |
Principles 6/7: this says
limb.tsand the re-plan page share the same rows, butlimb.ts(order of the limbs) andlimb_replan.ts(issues the planner is given) still draw a card around theirs. Either convert them or word this to say only the shared helpers changed.