Public: anyone can read this; the crew can change it.
A repository's reports pages speak the home page's language #9828
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9761-a-repository-s-reports-pages-speak-the-h → main 4 commits fork 6 threads · 6 unresolved
docs/design/areas/reports.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # Reports | |
| 2 | ||
| 3 | `/:owner/:name/reports` (`src/web/pages/reports.ts`) is headings over rows: each filer is a | |
| 4 | `<section class="settings-block">` with an `<h2>`, each report a `row()` in `<ul class="list setting-list">` whose meta keeps | |
| 5 | "From outside, …, not checked" and whose open `fold` holds a `.report-body`; none is a `card()`. | |
| 6 | The "Report sent" and "Nothing waiting" notices are plain text. `test/reports-page-shape.test.ts` holds the shape. | |
| 7 | ||
| 8 | - `.report-body`: the open body of a report row. Its message, hint, accept form (title field and button) and dismiss form | |
| 9 | stack as one grid column in reading order, so nothing shares a flex line and nothing scrolls sideways at 390px. | |
| 10 | Its 14px inset matches `.row` padding, so it lines up under the row title (a report row has no lead glyph) at every width. Styled in `src/web/style/components.ts`. |
src/web/pages/reports.ts +42 −55
| @@ -45,7 +45,7 @@ import { field, readForm } from "../form.ts"; | ||
| 45 | 45 | import { pageAddress } from "../studio/toolbar.ts"; |
| 46 | 46 | import { html, when, type Html } from "../html.ts"; |
| 47 | 47 | import { flashTo, page } from "../layout.ts"; |
| 48 | import { btn, card, empty, field as formField, messageText, time } from "../ui.ts"; | |
| 48 | import { btn, field as formField, messageText, row, time } from "../ui.ts"; | |
| 49 | 49 | |
| 50 | 50 | /** The repository, when it takes reports: public, and so seen by anyone. */ |
| 51 | 51 | function publicRepo(ctx: Ctx): Repo | undefined { |
| @@ -155,11 +155,10 @@ function reportBody(ctx: Ctx, repo: Repo): Response { | ||
| 155 | 155 | compact: true, |
| 156 | 156 | ownTitle: true, |
| 157 | 157 | })} |
| 158 | ${empty({ | |
| 159 | icon: "check", | |
| 160 | title: "Report sent", | |
| 161 | note: "A person who keeps this repository will read it.", | |
| 162 | })}`, | |
| 158 | <section class="settings-block"> | |
| 159 | <h1>Report sent</h1> | |
| 160 | <p>A person who keeps this repository will read it.</p> | |
| 161 | </section>`, | |
| 163 | 162 | }); |
| 164 | 163 | return reportForm(ctx, repo, { |
| 165 | 164 | title: "", |
| @@ -230,45 +229,37 @@ export async function reportSubmit(ctx: Ctx): Promise<Response> { | ||
| 230 | 229 | ); |
| 231 | 230 | } |
| 232 | 231 | |
| 233 | function reportCard(base: string, report: Report): Html { | |
| 232 | function reportRow(base: string, report: Report): Html { | |
| 234 | 233 | const action = (verb: "accept" | "dismiss") => |
| 235 | 234 | `${base}/reports/${report.id}/${verb}`; |
| 236 | const accept = `accept-${report.id}`; | |
| 237 | return card( | |
| 238 | html`<p><strong>${report.title}</strong></p> | |
| 239 | ${when( | |
| 240 | report.body !== "", | |
| 241 | () => messageText(report.body), | |
| 242 | )} | |
| 243 | ${when(report.page, (p) => html`<p class="hint">Sent from ${p}</p>`)} | |
| 244 | <form | |
| 245 | class="mt" | |
| 246 | id="${accept}" | |
| 247 | method="post" | |
| 248 | action="${action("accept")}" | |
| 249 | > | |
| 250 | ${formField({ | |
| 251 | label: "Title of the issue", | |
| 252 | name: "title", | |
| 253 | id: `title-${report.id}`, | |
| 254 | value: outsideWords(report.title), | |
| 255 | required: true, | |
| 256 | hint: `It opens as “${OUTSIDE_TITLE.trim()}” and then these words. Change the words if you like; the mark stays.`, | |
| 257 | })} | |
| 258 | </form>`, | |
| 259 | { | |
| 260 | tone: "attn", | |
| 261 | head: html`From outside, ${filerWords(report.filedBy)}, not checked · ${time(report.createdAt)}`, | |
| 262 | foot: html`${btn("Accept as an issue", { | |
| 263 | variant: "primary", | |
| 264 | icon: "issue", | |
| 265 | form: accept, | |
| 266 | })} | |
| 267 | <form method="post" action="${action("dismiss")}"> | |
| 235 | return row({ | |
| 236 | title: report.title, | |
| 237 | meta: html`From outside, ${filerWords(report.filedBy)}, not checked · ${time(report.createdAt)}`, | |
| 238 | fold: { | |
| 239 | id: `report-${report.id}`, | |
| 240 | open: true, | |
| 241 | body: html`<div class="report-body"> | |
| 242 | ${when(report.body !== "", () => messageText(report.body))} | |
| 243 | ${when(report.page, (p) => html`<p class="hint">Sent from ${p}</p>`)} | |
| 244 | <form id="accept-${report.id}" method="post" action="${action("accept")}"> | |
| 245 | ${formField({ | |
| 246 | label: "Title of the issue", | |
| 247 | name: "title", | |
| 248 | id: `title-${report.id}`, | |
| 249 | value: outsideWords(report.title), | |
| 250 | required: true, | |
| 251 | hint: `It opens as “${OUTSIDE_TITLE.trim()}” and then these words. Change the words if you like; the mark stays.`, | |
| 252 | })} | |
| 253 | <div class="form-actions"> | |
| 254 | ${btn("Accept as an issue", { variant: "primary", icon: "issue" })} | |
| 255 | </div> | |
| 256 | </form> | |
| 257 | <form class="form-actions" method="post" action="${action("dismiss")}"> | |
| 268 | 258 | ${btn("Dismiss", { variant: "ghost" })} |
| 269 | </form>`, | |
| 259 | </form> | |
| 260 | </div>`, | |
| 270 | 261 | }, |
| 271 | ); | |
| 262 | }); | |
| 272 | 263 | } |
| 273 | 264 | |
| 274 | 265 | const sameFiler = (a: FiledBy, b: FiledBy): boolean => |
| @@ -313,22 +304,18 @@ export function reportsPage(ctx: Ctx): Response { | ||
| 313 | 304 | sees one until you accept it; then it is your issue, with the words |
| 314 | 305 | quoted. |
| 315 | 306 | </p> |
| 316 | <div class="mt"> | |
| 317 | ${waiting.value.length === 0 | |
| 318 | ? empty({ | |
| 319 | icon: "check", | |
| 320 | title: "Nothing waiting", | |
| 321 | note: `Anyone can report a problem at ${base}/report.`, | |
| 322 | }) | |
| 323 | : byFiler(waiting.value).map( | |
| 324 | (g) => html`<section class="mt"> | |
| 325 | <h2>${groupTitle(g)} · ${g.count}</h2> | |
| 307 | ${waiting.value.length === 0 | |
| 308 | ? html`<p class="muted small mt">Nothing is waiting at <a href="${base}/report">${base}/report</a>.</p>` | |
outdated
Principle 1 (nothing explains itself twice): the intro paragraph above already says what the page is for. Also, |
||
| 309 | : byFiler(waiting.value).map( | |
| 310 | (g) => html`<section class="settings-block"> | |
| 311 | <h2>${groupTitle(g)} · ${g.count}</h2> | |
| 312 | <ul class="list setting-list"> | |
| 326 | 313 | ${waiting.value |
| 327 | 314 | .filter((r) => sameFiler(r.filedBy, g)) |
| 328 | .map((r) => reportCard(base, r))} | |
| 329 | </section>`, | |
| 330 | )} | |
| 331 | </div>`, | |
| 315 | .map((r) => reportRow(base, r))} | |
| 316 | </ul> | |
| 317 | </section>`, | |
| 318 | )}`, | |
| 332 | 319 | }); |
| 333 | 320 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 334 | 321 | return response; |
src/web/style/components.ts +3
| @@ -492,6 +492,9 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 492 | 492 | summary.row { cursor: pointer; list-style: none; } |
| 493 | 493 | summary.row:hover { background: var(--muted); } |
| 494 | 494 | details[open] > summary.row > .row-lead { transform: rotate(90deg); } |
| 495 | /* A report's open body: block children in reading order, never one flex line. */ | |
| 496 | .report-body { padding: 0 14px 14px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; min-width: 0; overflow-wrap: anywhere; } | |
| 497 | .report-body form { margin: 0; } | |
| 495 | 498 | /* A count with its glyph: comments on a row, members on a team. */ |
outdated
The 390 px behaviour rests on this one media query and nobody has looked at it. Verify it on a seeded page; |
||
| 496 | 499 | .count { |
| 497 | 500 | display: inline-flex; align-items: center; gap: 4px; |
test/reports-page-shape.test.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** The reports pages are headings over rows, not cards (#9761). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { components } from "../src/web/style/components.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { statements } from "./helpers/statements.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | let dir = ""; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let token = ""; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dir = await mkdtemp(join(tmpdir(), "forge-reports-shape-")); | |
| 23 | app = createApp({ dataDir: dir, port: 0 }); | |
| 24 | servers = await serve(app); | |
| 25 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 26 | if (!a.ok) throw new Error(a.error); | |
| 27 | const made = await createRepo(app, a.value, { name: "game", visibility: "public" }); | |
| 28 | if (!made.ok) throw new Error(String(made.error)); | |
| 29 | token = createToken(app.db, a.value.id, "t", "rw").token; | |
| 30 | }); | |
| 31 | ||
| 32 | afterAll(async () => { | |
| 33 | servers.stop(); | |
| 34 | app.close(); | |
| 35 | await rm(dir, { recursive: true, force: true }); | |
| 36 | }); | |
| 37 | ||
| 38 | /** Statements the warm reports page ran as cards (902f53c7), measured with one report waiting. */ | |
| 39 | const BUDGET = 34; | |
| 40 | const main = (page: string) => page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 41 | ||
| 42 | test("the queue is a heading over rows, escaped, with its forms and no card", async () => { | |
| 43 | const res = await fetch(`${servers.url}/alice/game/report`, { | |
| 44 | method: "POST", | |
| 45 | headers: { origin: servers.url }, | |
| 46 | body: new URLSearchParams({ title: "<b>broken</b>", body: "it <i>fails</i>" }), | |
| 47 | redirect: "manual", | |
| 48 | }); | |
| 49 | expect(res.status).toBe(303); | |
| 50 | const page = await (await fetch(`${servers.url}/alice/game/reports`, { headers: { authorization: `Bearer ${token}` } })).text(); | |
| 51 | const m = main(page); | |
| 52 | expect(m).toContain('<section class="settings-block">'); | |
| 53 | expect(m).toContain("<h2>Anonymous · 1</h2>"); | |
| 54 | expect(m).toContain('<ul class="list setting-list">'); | |
| 55 | expect(m).toContain("From outside, anonymous, not checked"); | |
| 56 | expect(m).toContain("<b>broken</b>"); | |
| 57 | expect(m).not.toContain("<b>broken"); | |
| 58 | expect(m).toMatch(/action="\/alice\/game\/reports\/[^"]+\/accept"/); | |
| 59 | expect(m).toMatch(/action="\/alice\/game\/reports\/[^"]+\/dismiss"/); | |
| 60 | expect(m).not.toContain('class="card'); | |
| 61 | expect(m).not.toContain("card-body"); | |
| 62 | // Nothing rides in the one-line right-aligned slot; the actions live in the | |
| 63 | // row's own block, accept's button inside its form, dismiss a POST button. | |
| 64 | expect(m).not.toContain("row-after"); | |
| 65 | const li = m.slice(m.indexOf("<li"), m.indexOf("</li>")); | |
| 66 | const accept = li.match(/<form[^>]*action="[^"]*\/accept"[^>]*>([\s\S]*?)<\/form>/); | |
| 67 | expect(accept).not.toBeNull(); | |
outdated
The test asserts only the absence of |
||
| 68 | expect(accept![1]).toContain('name="title"'); | |
| 69 | expect(accept![1]).toMatch(/<button[^>]*>[\s\S]*Accept as an issue/); | |
| 70 | const dismiss = li.match(/<form[^>]*method="post"[^>]*action="[^"]*\/dismiss"[^>]*>([\s\S]*?)<\/form>/); | |
| 71 | expect(dismiss).not.toBeNull(); | |
| 72 | expect(dismiss![1]).toMatch(/<button[^>]*>[\s\S]*Dismiss/); | |
| 73 | expect(li).toContain('class="report-body"'); | |
| 74 | }); | |
| 75 | ||
| 76 | test("an empty queue and the sent notice are plain text, not cards", async () => { | |
| 77 | const sent = main(await (await fetch(`${servers.url}/alice/game/report?sent=1`)).text()); | |
| 78 | expect(sent).toContain("Report sent"); | |
| 79 | expect(sent).not.toContain('class="card'); | |
| 80 | const form = main(await (await fetch(`${servers.url}/alice/game/report`)).text()); | |
| 81 | expect(form).toContain('action="/alice/game/report"'); | |
| 82 | expect(form).not.toContain('class="card'); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a report's body is one shrinkable column: nothing in it can push the page sideways at 390px", () => { | |
| 86 | const rules = [...components.matchAll(/(^|\n)([^\n{}]*\.report-body[^{]*)\{([^}]*)\}/g)].map((m) => ({ | |
| 87 | selector: m[2]!.trim(), | |
| 88 | body: m[3]!, | |
| 89 | })); | |
| 90 | const own = rules.find((r) => r.selector === ".report-body"); | |
| 91 | expect(own).toBeDefined(); | |
| 92 | // A grid's implicit `auto` column grows to its widest child; minmax(0, 1fr) | |
| 93 | // lets it shrink to the viewport, and the children wrap instead. | |
| 94 | expect(own!.body).toContain("display: grid"); | |
| 95 | expect(own!.body).toContain("grid-template-columns: minmax(0, 1fr)"); | |
| 96 | expect(own!.body).toContain("min-width: 0"); | |
| 97 | expect(own!.body).toContain("overflow-wrap: anywhere"); | |
| 98 | // Its inset is the row's own 14px, so it lines up under the row title. | |
| 99 | expect(own!.body).toContain("padding: 0 14px 14px"); | |
| 100 | // No rule on it sets a fixed width or puts its parts on one flex line. | |
| 101 | for (const r of rules) { | |
| 102 | expect(r.body).not.toMatch(/(^|[;\s])(min-)?width:\s*\d+px/); | |
| 103 | expect(r.body).not.toMatch(/display:\s*(inline-)?flex/); | |
| 104 | expect(r.body).not.toMatch(/white-space:\s*nowrap/); | |
| 105 | } | |
| 106 | }); | |
| 107 | ||
| 108 | test("the warm reports page runs no more statements than it did as cards", async () => { | |
| 109 | await fetch(`${servers.url}/alice/game/reports`, { headers: { authorization: `Bearer ${token}` } }); | |
| 110 | const used = await statements(app.db, async () => { | |
| 111 | const res = await fetch(`${servers.url}/alice/game/reports`, { headers: { authorization: `Bearer ${token}` } }); | |
| 112 | expect(res.status).toBe(200); | |
| 113 | await res.text(); | |
| 114 | }); | |
| 115 | expect(used).toBeLessThanOrEqual(BUDGET); | |
| 116 | }); |
test/reports.test.ts +6 −3
| @@ -418,9 +418,9 @@ describe("reports from outside", () => { | ||
| 418 | 418 | expect(page).toContain( |
| 419 | 419 | '<div class="prose" data-text="message"><pre>Menu <b>breaks</b>\n on phones</pre></div>', |
| 420 | 420 | ); |
| 421 | // The card of the bodiless report, from its opening to its accept form. | |
| 421 | // The row of the bodiless report, from its opening to its accept form. | |
| 422 | 422 | const end = page.indexOf(`id="accept-${without}"`); |
| 423 | const card = page.slice(page.lastIndexOf('class="card', end), end); | |
| 423 | const card = page.slice(page.lastIndexOf('<li', end), end); | |
| 424 | 424 | expect(card).toContain("silent bug"); |
| 425 | 425 | expect(card).not.toContain('data-text="message"'); |
| 426 | 426 | expect(page.indexOf(`id="accept-${withBody}"`)).toBeGreaterThan(-1); |
| @@ -431,7 +431,10 @@ describe("reports from outside", () => { | ||
| 431 | 431 | const id = (await waiting("menu bad"))!; |
| 432 | 432 | const page = await (await as(aliceToken, "/alice/game/reports")).text(); |
| 433 | 433 | expect(page).toContain(`id="accept-${id}"`); |
| 434 | expect(page).toContain(`form="accept-${id}"`); | |
| 434 | // The Accept button sits inside its own form, not beside it. | |
| 435 | const acceptForm = page.match(new RegExp(`<form id="accept-${id}"[^>]*>([\\s\\S]*?)</form>`)); | |
| 436 | expect(acceptForm).not.toBeNull(); | |
outdated
This regex is loose: |
||
| 437 | expect(acceptForm![1]).toMatch(/<button[^>]*>[\s\S]*Accept as an issue[\s\S]*<\/button>/); | |
| 435 | 438 | expect(page).toMatch( |
| 436 | 439 | new RegExp(`id="title-${id}"[^>]*name="title"[^>]*value="menu bad"`), |
| 437 | 440 | ); |
Principles 3 and 7:
afterrenders inside.row-after, which.setting-liststyles as a right-aligned wrapping flex line (components.ts:481) for one small control. Here it holds a message div, a hint, a form with a text input, the Accept button and a second form. They become flex siblings, jammed right on desktop and wrapped unpredictably at 390px (below 640px the padding is 0 14px 10px, with no room for the input). Give the report body and the accept field their own block (for example the body and hint inmeta, or afoldwith the form in its body) so each action is reachable by thumb and keyboard in reading order. Open the preview at 390px and confirm no sideways scroll; the builder said this was never checked.