Public: anyone can read this; the crew can change it.
A wave's page opens with one sentence of what changed for a person, then the counts #11532
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11435-a-wave-s-page-opens-with-one-sentence-of → main 8 commits fork 2 threads · 2 unresolved
11 files
changed +96
−5
docs/features/wave-walk.md +4
| @@ -23,6 +23,10 @@ When a wave's pulls closed issues under a milestone, the wave opens as its headl | ||
| 23 | 23 | |
| 24 | 24 | Only pulls the viewer may read are listed, and Enter re-checks that before opening one. The walk is tested in `test/story-walk.test.ts` and `test/tui-waves.test.ts`. |
| 25 | 25 | |
| 26 | ## What a wave changed, in one sentence | |
| 27 | ||
| 28 | Under the headline a wave says in one plain sentence what landed, from the titles of its merged pulls the viewer may read: `This wave landed Add search, Fix the nav and 3 more.`, or `No pull in this wave has landed yet.` It is built from titles alone (no model), capped at 160 characters, escaped on the page and stripped of control characters in the terminal. The changelog links on to the forge's waves ("Read it by wave"). Tested in `test/wave-sentence.test.ts`, `test/wave-pulls.test.ts`, `test/tui-waves.test.ts` and `test/changelog.test.ts`. | |
| 29 | ||
| 26 | 30 | ## A wave's size |
| 27 | 31 | |
| 28 | 32 | Each wave says how big it is in plain numbers, such as `7 changes in 3 apps`: on the list row, under the headline on the wave's page, on the terminal's waves list and at the top of a wave there, and as `pulls` and `apps` on each item of `GET /api/v1/repos/<owner>/<repo>/waves`. The numbers count only the pulls you can read, once each, so a guest is not told how many private pulls a wave holds. |
src/story/words.ts +30
| @@ -26,3 +26,33 @@ export function deciderSentence<T>(count: (pile: DeciderPile) => T): (string | T | ||
| 26 | 26 | ".", |
| 27 | 27 | ]; |
| 28 | 28 | } |
| 29 | ||
| 30 | /** The longest a wave's opening sentence runs, in characters. */ | |
| 31 | export const WAVE_SENTENCE_CAP = 160; | |
| 32 | /** Up to this many landed titles are named in the sentence; more are counted. */ | |
| 33 | export const WAVE_NAMED_MAX = 2; | |
| 34 | /** Said when no pull of the wave has landed. */ | |
| 35 | export const WAVE_NOTHING_LANDED = "No pull in this wave has landed yet."; | |
| 36 | ||
| 37 | const squash = (s: string): string => s.replace(/[\u0000-\u001f\u007f\s]+/g, " ").trim().replace(/[.!?]+$/, ""); | |
| 38 | ||
| 39 | /** | |
| 40 | * One plain sentence of what a wave changed, from the titles of its landed | |
| 41 | * pulls: deterministic, no model, at most `WAVE_SENTENCE_CAP` characters. The | |
| 42 | * titles are the writers' words and come back unescaped; the page escapes them. | |
| 43 | */ | |
| 44 | export function waveSentence(titles: readonly string[], cap = WAVE_SENTENCE_CAP): string { | |
| 45 | const all = titles.map(squash).filter((t) => t !== ""); | |
| 46 | if (all.length === 0) return WAVE_NOTHING_LANDED; | |
| 47 | // Many titles would only be the pull rows said twice: count them, name the first. | |
| 48 | if (all.length > WAVE_NAMED_MAX) { | |
| 49 | const head = `${all.length} changes landed, starting with `; | |
| 50 | const keep = Math.max(1, cap - head.length - 2); | |
| 51 | const first = all[0]!; | |
| 52 | return `${head}${first.length > keep ? `${first.slice(0, keep - 1).trimEnd()}…` : first}.`; | |
| 53 | } | |
| 54 | const lead = "This wave landed "; | |
| 55 | const text = all.join(" and "); | |
| 56 | const room = cap - lead.length - 1; | |
| 57 | return `${lead}${text.length > room ? `${text.slice(0, room - 1).trimEnd()}…` : text}.`; | |
| 58 | } |
src/tui/screens/waves.ts +2 −1
| @@ -13,7 +13,7 @@ import { milestonePath, planNow } from "../../story/wave_plan.ts"; | ||
| 13 | 13 | import { repoBase } from "../../web/chrome.ts"; |
| 14 | 14 | import { carriedPulls, pullPath, type CarriedPull } from "../../story/wave_pulls.ts"; |
| 15 | 15 | import { pullRefs, readWave, readWaves, sizeWords, waveLine, waveSize, type WaveEntry } from "../../story/waves.ts"; |
| 16 | import { NO_WAVES } from "../../story/words.ts"; | |
| 16 | import { NO_WAVES, waveSentence } from "../../story/words.ts"; | |
| 17 | 17 | import type { RepoSource } from "../../view/source.ts"; |
| 18 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| 19 | 19 | import { binding } from "../app/keys.ts"; |
| @@ -59,6 +59,7 @@ export function waveLines( | ||
| 59 | 59 | const w = entry.wave; |
| 60 | 60 | return [ |
| 61 | 61 | plain(w.headline), |
| 62 | plain(waveSentence(pulls.filter((p) => p.mergedAt !== null).map((p) => p.title))), | |
| 62 | 63 | // Sized over `pulls`, the ones this viewer may see, as the page's header is. |
| 63 | 64 | `${plain(w.version)} ${plain(w.date)} ${sizeWords(waveSize(pulls))}`, |
| 64 | 65 | ...(moved.length === 0 ? [] : [plain(waveProgress(moved))]), |
src/web/pages/changelog.ts +1 −1
| @@ -233,7 +233,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 233 | 233 | { label: "Every bet", href: "/changelog", current: bet === "" }, |
| 234 | 234 | ...bets.map((b) => ({ label: b, href: `/changelog?bet=${encodeURIComponent(b)}`, current: bet === b })), |
| 235 | 235 | ]); |
| 236 | const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${nearBlock(planned === undefined ? "" : repoBase(planned.repo), near)}${filters}${betFilters} | |
| 236 | const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${nearBlock(planned === undefined ? "" : repoBase(planned.repo), near)}${filters}${betFilters}${read.own !== undefined && readable ? html`<p class="small"><a href="/${read.own}/waves" data-waves-link>Read it by wave</a></p>` : null} | |
| 237 | 237 | <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", toLive, readable)}</div>`; |
| 238 | 238 | const response = page({ ctx, title: "Changelog", body, feed: { href: "/changelog.atom", title: "Changelog" }, json: { href: `/api/v1/changelog${choice === read.own ? "" : `?repo=${encodeURIComponent(choice)}`}`, title: "Changelog (JSON)" }, scriptJs: `${liveRegionsJs}\n${localDaysJs}` }); |
| 239 | 239 | notePhase(ctx.request, "render", performance.now() - rendering); |
src/web/pages/waves.ts +2 −1
| @@ -17,7 +17,7 @@ import { VERSION, type Wave } from "../../story/wave.ts"; | ||
| 17 | 17 | import { movedWithPulls, waveProgress } from "../../story/wave_moved.ts"; |
| 18 | 18 | import { carriedPulls, pullPath } from "../../story/wave_pulls.ts"; |
| 19 | 19 | import { fromWave, pullRefs, readWave, readWaves, sizeWords, waveSize } from "../../story/waves.ts"; |
| 20 | import { NO_WAVES } from "../../story/words.ts"; | |
| 20 | import { NO_WAVES, waveSentence } from "../../story/words.ts"; | |
| 21 | 21 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; |
| 22 | 22 | import { escapeHtml, html } from "../html.ts"; |
| 23 | 23 | import { page } from "../layout.ts"; |
| @@ -227,6 +227,7 @@ export async function wavePage(ctx: Ctx): Promise<Response> { | ||
| 227 | 227 | |
| 228 | 228 | const content = entry.ok |
| 229 | 229 | ? html`<h1>${entry.wave.headline}</h1> |
| 230 | <p data-wave-sentence>${waveSentence(carried.filter((p) => p.mergedAt !== null).map((p) => p.title))}</p> | |
Principle 1: titles are said here and again in the pull rows below. Keep the sentence short and about what changed, not a second list of titles. Non-blocking (principle 1): for one or two pulls this sentence repeats the titles of the rows below. Prefer count plus first title in all cases, then the wave-folds test edit is unneeded. |
||
| 230 | 231 | <p class="muted">${entry.wave.version} · ${entry.wave.date} · <span data-wave-size>${sizeWords(waveSize(carried))}</span></p> |
| 231 | 232 | ${line === "" |
| 232 | 233 | ? "" |
src/web/style/content.ts +2
| @@ -21,6 +21,8 @@ export const content = /* css */ ` | ||
| 21 | 21 | @media (max-width: 640px) { |
| 22 | 22 | .repo-head p.muted.small a { display: inline-block; padding: 12px 0; min-height: 44px; box-sizing: border-box; } |
| 23 | 23 | } |
| 24 | /* The by-wave link under the changelog filters: a thumb-sized target, no new class. */ | |
| 25 | a[data-waves-link] { display: inline-flex; align-items: center; min-height: 44px; } | |
| 24 | 26 | .repo-head p.muted.small { margin-top: 4px; text-wrap: pretty; } |
| 25 | 27 | .repo-head .tabs, .repo-head .repo-nav { margin-top: 12px; } |
| 26 | 28 | .repo-nav { display: flex; align-items: flex-start; gap: 4px; border-bottom: 1px solid var(--border); } |
test/changelog.test.ts +5
| @@ -103,6 +103,11 @@ describe("changelog page", () => { | ||
| 103 | 103 | expect(body).not.toContain("A note app change"); |
| 104 | 104 | }); |
| 105 | 105 | |
| 106 | test("it links on to the forge's waves", async () => { | |
| 107 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 108 | expect(body).toContain('href="/alice/forge/waves" data-waves-link'); | |
| 109 | }); | |
| 110 | ||
| 106 | 111 | test("another repository is one filter away, and an unknown one falls back to this forge", async () => { |
| 107 | 112 | const all = await (await fetch(`${servers.url}/changelog?repo=all`)).text(); |
| 108 | 113 | expect(all).toContain("A note app change"); |
test/tui-waves.test.ts +8
| @@ -264,6 +264,14 @@ describe("waves in the terminal", () => { | ||
| 264 | 264 | expect(h.text).toContain("Not a wave:"); |
| 265 | 265 | }); |
| 266 | 266 | |
| 267 | test("a wave opens with the same sentence as the page, under the headline", () => { | |
| 268 | const entry = { ok: true as const, file: "1.0.0.md", wave: { version: "1.0.0", date: "2026-01-01", headline: "Head", highlights: [], pulls: [] } }; | |
| 269 | const lines = waveLines(entry, [{ owner: "o", name: "r", number: 1, title: "Add \u001b[2Jsearch", mergedAt: "2026-01-01", state: "closed" }]); | |
| 270 | expect(lines[0]).toBe("Head"); | |
| 271 | expect(lines[1]).toBe("This wave landed Add [2Jsearch."); | |
| 272 | expect(waveLines(entry, [])[1]).toBe("No pull in this wave has landed yet."); | |
| 273 | }); | |
| 274 | ||
| 267 | 275 | test("wave text carries no escape or control characters", () => { |
| 268 | 276 | const lines = waveLines( |
| 269 | 277 | { |
test/wave-folds.test.ts +4 −2
| @@ -101,8 +101,10 @@ describe("wave folds", () => { | ||
| 101 | 101 | // progress is said once, and each pull once (in its fold, not again under Pulls) |
| 102 | 102 | expect(text.split("1 of 2 done").length - 1).toBe(1); |
| 103 | 103 | expect(text).not.toContain("1 of 1 done"); |
| 104 | expect(text.split("Cache work").length - 1).toBe(1); | |
| 105 | expect(text.split("Mail work").length - 1).toBe(1); | |
| 104 | // (the opening sentence names the titles on purpose; the list below it does not repeat them) | |
| 105 | const listed = text.replace(/<p data-wave-sentence>.*?<\/p>/s, ""); | |
| 106 | expect(listed.split("Cache work").length - 1).toBe(1); | |
| 107 | expect(listed.split("Mail work").length - 1).toBe(1); | |
| 106 | 108 | }); |
| 107 | 109 | |
| 108 | 110 | test("a wave with no milestone has no progress line", async () => { |
test/wave-pulls.test.ts +15
| @@ -179,3 +179,18 @@ describe("carriedPulls", () => { | ||
| 179 | 179 | expect(carriedPulls(app.db, ref, undefined)[0]?.title).toBe("Renamed idea"); |
| 180 | 180 | }); |
| 181 | 181 | }); |
| 182 | ||
| 183 | test("the page opens with one escaped sentence of what landed, and says when nothing did", async () => { | |
| 184 | const body = await (await fetch(`${servers.url}/alice/pub/waves/0.1.0`, { headers: { cookie: bobCookie } })).text(); | |
| 185 | const start = body.indexOf("data-wave-sentence>") + "data-wave-sentence>".length; | |
| 186 | const text = body.slice(start, body.indexOf("</p>", start)); | |
| 187 | expect(start).toBeGreaterThan("data-wave-sentence>".length - 1); | |
| 188 | expect(text).toContain("This wave landed "); | |
| 189 | expect(text).toContain("Add the <b>thing</b>"); | |
| 190 | expect(text).toContain("Fix the other"); | |
| 191 | expect(text).not.toContain("<b>"); | |
| 192 | expect(text).not.toContain("Dropped idea"); | |
| 193 | expect(body.indexOf("data-wave-sentence")).toBeLessThan(body.indexOf("data-wave-size")); | |
| 194 | const none = await (await fetch(`${servers.url}/alice/pub/waves/0.2.0`, { headers: { cookie: bobCookie } })).text(); | |
| 195 | expect(none).toContain("No pull in this wave has landed yet."); | |
| 196 | }); |
test/wave-sentence.test.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { waveSentence, WAVE_NOTHING_LANDED, WAVE_SENTENCE_CAP } from "../src/story/words.ts"; | |
| 3 | ||
| 4 | test("names one or two landed titles in one sentence", () => { | |
| 5 | expect(waveSentence(["Add search", "Fix the nav."])).toBe("This wave landed Add search and Fix the nav."); | |
| 6 | expect(waveSentence(["Add search"])).toBe("This wave landed Add search."); | |
| 7 | }); | |
| 8 | test("many titles are counted, not listed again", () => { | |
| 9 | expect(waveSentence(["a", "b", "c"])).toBe("3 changes landed, starting with a."); | |
| 10 | }); | |
| 11 | test("says so when nothing landed", () => { | |
| 12 | expect(waveSentence([])).toBe(WAVE_NOTHING_LANDED); | |
| 13 | }); | |
| 14 | test("is capped", () => { | |
| 15 | const titles = Array.from({ length: 30 }, (_, i) => `Change number ${i}`); | |
| 16 | expect(waveSentence(titles)).toMatch(/^30 changes landed/); | |
| 17 | expect(waveSentence(["x".repeat(500), "y"]).length).toBeLessThanOrEqual(WAVE_SENTENCE_CAP); | |
| 18 | expect(waveSentence(["x".repeat(500), "y", "z"]).length).toBeLessThanOrEqual(WAVE_SENTENCE_CAP); | |
| 19 | }); | |
| 20 | test("leaves markup for the page to escape; control characters become spaces", () => { | |
| 21 | expect(waveSentence(["<b>hi</b>"])).toContain("<b>hi</b>"); | |
| 22 | expect(waveSentence(["a\u0007b\nc"])).toBe("This wave landed a b c."); | |
| 23 | }); |
Principle 1/phone: a 13px link with no 44px target; give it a tap-sized hit area. Not visible in the preview's /changelog; verify it renders.