Public: anyone can read this; the crew can change it.
The waves page speaks the home page's language, with looks before and after #9696
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9649-the-waves-page-speaks-the-home-page-s-la → main 5 commits fork 3 threads · 3 unresolved
3 files
changed +52
−9
docs/design/areas/waves.md +9
| @@ -8,3 +8,12 @@ title and meta wrap and break long words anywhere, in place of the list's | ||
| 8 | 8 | one-line ellipsis. No fixed width; the page does not scroll sideways. No new |
| 9 | 9 | class. The owner's phone look of `/preview-admin/sample/waves` is attached to |
| 10 | 10 | the pull from its preview. |
| 11 | ||
| 12 | ## The list reads as headlines (#9649) | |
| 13 | ||
| 14 | The page's `<h1>` is `sr-only` "Waves"; the list is the shared `.list` of | |
| 15 | `row()`s with no card and no heading over it. A row is the headline, the first | |
| 16 | highlight's why as the one sentence under it (omitted when empty), and | |
| 17 | `version · date · N pulls` as a muted trail. No tooltip repeats it. No new | |
| 18 | class and no CSS beyond the wrapping above. The terminal's waves screen | |
| 19 | (`src/tui/screens/waves.ts`) is unchanged. |
src/web/pages/waves.ts +20 −5
| @@ -42,6 +42,17 @@ export function waveLine(w: Wave, visible: number): string { | ||
| 42 | 42 | return `${w.version} · ${w.date} · ${w.headline} · ${visible} ${visible === 1 ? "pull" : "pulls"}`; |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | /** One wave on the list: its headline, the first highlight's why as the one sentence under it, and version, date and pull count as a muted trail. */ | |
| 46 | export function waveRow(w: Wave, href: string, visible: number) { | |
| 47 | const why = w.highlights[0]?.why; | |
| 48 | return row({ | |
| 49 | href, | |
| 50 | title: w.headline, | |
| 51 | ...(why === undefined || why === "" ? {} : { meta: html`${why}` }), | |
| 52 | trail: html`<span class="muted small">${w.version} · ${w.date} · ${visible} ${visible === 1 ? "pull" : "pulls"}</span>`, | |
| 53 | }); | |
| 54 | } | |
| 55 | ||
| 45 | 56 | // GET /:owner/:name/waves |
| 46 | 57 | export async function wavesPage(ctx: Ctx): Promise<Response> { |
| 47 | 58 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -60,11 +71,12 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 60 | 71 | |
| 61 | 72 | const rows = entries.map((e) => |
| 62 | 73 | e.ok |
| 63 | ? row({ | |
| 64 | href: fileHref(e.file), | |
| 74 | ? waveRow( | |
| 75 | e.wave, | |
| 76 | fileHref(e.file), | |
| 65 | 77 | // The wave's own page lists these same pulls, so the two agree. |
| 66 | title: waveLine(e.wave, carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user).length), | |
| 67 | }) | |
| 78 | carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user).length, | |
| 79 | ) | |
| 68 | 80 | : row({ |
| 69 | 81 | href: fileHref(e.file), |
| 70 | 82 | title: e.file, |
| @@ -74,6 +86,7 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 74 | 86 | |
| 75 | 87 | const body = html`${scopeHeader(ctx, scope, "none", { compact: true })} |
| 76 | 88 | <div class="mt" data-waves> |
| 89 | <h1 class="sr-only">Waves</h1> | |
| 77 | 90 | ${entries.length === 0 |
| 78 | 91 | ? empty({ |
| 79 | 92 | title: "No waves yet", |
| @@ -86,7 +99,9 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 86 | 99 | } |
| 87 | 100 | : {}), |
| 88 | 101 | }) |
| 89 | : listCard(rows, { head: html`<h2>Waves</h2>` })} | |
| 102 | : html`<ul class="list"> | |
| 103 | ${rows} | |
| 104 | </ul>`} | |
| 90 | 105 | </div>`; |
| 91 | 106 | |
| 92 | 107 | const response = page({ |
test/waves-page.test.ts +23 −4
| @@ -9,6 +9,7 @@ import { createSession } from "../src/core/sessions.ts"; | ||
| 9 | 9 | import { createUser, type User } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | 11 | import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts"; |
| 12 | import { waveRow } from "../src/web/pages/waves.ts"; | |
| 12 | 13 | import { writeWave, type Wave } from "../src/story/wave.ts"; |
| 13 | 14 | |
| 14 | 15 | const PASSWORD = "a-good-passphrase"; |
| @@ -105,8 +106,8 @@ describe("waves page", () => { | ||
| 105 | 106 | expect(at("Tenth")).toBeGreaterThan(-1); |
| 106 | 107 | expect(at("Tenth")).toBeLessThan(at("Ninth")); |
| 107 | 108 | expect(at("Ninth")).toBeLessThan(at("Zeta")); |
| 108 | expect(body).toContain("0.10.0 · 2026-02-01 · Tenth · 0 pulls"); | |
| 109 | expect(body).toContain("0.9.0 · 2026-01-01 · Ninth · 0 pulls"); | |
| 109 | expect(body).toContain("0.10.0 · 2026-02-01 · 0 pulls"); | |
| 110 | expect(body).toContain("0.9.0 · 2026-01-01 · 0 pulls"); | |
| 110 | 111 | expect(at("0.10.0 · 2026-02-01")).toBeLessThan(at("0.9.0 · 2026-01-01")); |
| 111 | 112 | expect(body).toContain('href="/alice/pub/waves/0.10.0"'); |
| 112 | 113 | }); |
| @@ -226,9 +227,9 @@ describe("waves page", () => { | ||
| 226 | 227 | |
| 227 | 228 | test("a row counts the pulls this viewer can see, as the wave's page lists them", async () => { |
| 228 | 229 | const body = await (await get("/alice/pub/waves")).text(); |
| 229 | expect(body).toContain("0.1.0 · 2026-03-05 · Mixed · 2 pulls"); | |
| 230 | expect(body).toContain("0.1.0 · 2026-03-05 · 2 pulls"); | |
| 230 | 231 | const guest = await (await get("/alice/pub/waves", false)).text(); |
outdated
This only adjusts an old assertion. Add tests that the highlight's why is in the row, the sr-only h1 is present, and the card heading is gone. |
||
| 231 | expect(guest).toContain("0.1.0 · 2026-03-05 · Mixed · 1 pull<"); | |
| 232 | expect(guest).toContain("0.1.0 · 2026-03-05 · 1 pull<"); | |
| 232 | 233 | expect(guest).not.toContain("2 pulls"); |
| 233 | 234 | expect(guest).not.toContain("Secret work"); |
| 234 | 235 | const page = await (await get("/alice/pub/waves/0.1.0", false)).text(); |
| @@ -236,6 +237,24 @@ describe("waves page", () => { | ||
| 236 | 237 | expect(page).not.toContain("Secret work"); |
| 237 | 238 | }); |
| 238 | 239 | |
| 240 | test("a row is the headline with the first highlight's why under it, in a list with no card or Waves heading", async () => { | |
| 241 | const body = await (await get("/alice/pub/waves")).text(); | |
| 242 | expect(body).toContain('<h1 class="sr-only">Waves</h1>'); | |
| 243 | expect(body).not.toContain("<h2>Waves</h2>"); | |
| 244 | expect(body).not.toContain('class="card'); | |
| 245 | expect(body).toMatch(/class="row-title"[^>]*>Tenth<[\s\S]*?class="row-meta">\s*because\s*</); | |
| 246 | expect(body).not.toContain("title=\"0.10.0 ·"); | |
| 247 | }); | |
| 248 | ||
| 249 | test("a wave whose first why is empty has a row with no sentence and no stray separator", () => { | |
| 250 | const w = { ...wave("0.9.0", "2026-01-01", "Quiet"), highlights: [{ title: "T", why: "" }] }; | |
| 251 | const r = waveRow(w, "/x", 1).text; | |
| 252 | expect(r).toContain("Quiet"); | |
| 253 | expect(r).not.toContain("row-meta"); | |
| 254 | expect(r).toContain("0.9.0 · 2026-01-01 · 1 pull"); | |
| 255 | expect(r).not.toMatch(/·\s*·/); | |
| 256 | }); | |
| 257 | ||
| 239 | 258 | test("waves.atom titles an entry with the headline", async () => { |
| 240 | 259 | const body = await (await get("/alice/pub/waves.atom")).text(); |
| 241 | 260 | expect(body).toContain("<title>Tenth</title>"); |
Principle 1/8: the meta line packs why, date, version and count into one muted run, and the same facts repeat in the title tooltip, so the row says itself twice. Keep the one sentence and drop the duplicate tooltip. Also needs a test for the no-highlight case.