Public: anyone can read this; the crew can change it.
The /audit page speaks the home page's language #9729
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9710-the-audit-page-speaks-the-home-page-s-la → main 2 commits fork
3 files
changed +63
−45
docs/design/areas/lists.md +9
| @@ -26,3 +26,12 @@ The proofs under a forge on `/forges` (#7959) are `row()` `children`, one per pu | ||
| 26 | 26 | one-line ellipsis. No fixed width; the page does not scroll sideways. No new class. The owner's phone look of |
| 27 | 27 | `/preview-admin/sample/pulls` is attached to the pull from its preview. |
| 28 | 28 | - `.archived-list` — the rows of an origin's archived repositories: long names wrap, and on a phone each row's buttons drop below it. |
| 29 | ||
| 30 | ## The audit as headings over rows (#9710) | |
| 31 | ||
| 32 | `/audit` (`auditPage` in `src/web/pages/audit.ts`) has no boxed card. Each app's standing (its policy table with the | |
| 33 | last probe under it, an unsound contract's problems, the Unread rows) and each "What changed" part (pulls at the wall, | |
| 34 | contract changes, doors over time) is a `.card.bare`: an `<h3>` over its rows on the page's one background, with no | |
| 35 | frame, no tone and no icon before the heading. The state is a badge's word ("contract unsound", "1 door disobeys", | |
| 36 | "at the wall"), so dropping the tone loses nothing. The table, the `id` each app section is linked by | |
| 37 | (`/audit#owner-name`) and the probe form do not change. No new class. Pinned by `test/audit.test.ts`. |
src/web/pages/audit.ts +39 −43
| @@ -49,7 +49,6 @@ import { | ||
| 49 | 49 | card, |
| 50 | 50 | empty, |
| 51 | 51 | icon, |
| 52 | listCard, | |
| 53 | 52 | row, |
| 54 | 53 | sha, |
| 55 | 54 | time, |
| @@ -148,16 +147,16 @@ export async function auditPage(ctx: Ctx): Promise<Response> { | ||
| 148 | 147 | ), |
| 149 | 148 | )} |
| 150 | 149 | </h2> |
| 151 | ${unsound.map(unsoundCard)} | |
| 150 | ${unsound.map(unsoundSection)} | |
| 152 | 151 | ${sound.map((a) => |
| 153 | policyCard( | |
| 152 | policySection( | |
| 154 | 153 | a, |
| 155 | 154 | probed.get(records.get(a.repo.id)!.id), |
| 156 | 155 | ctx.auth.user !== undefined && |
| 157 | 156 | canWrite(ctx.app.db, a.repo, ctx.auth.user), |
| 158 | 157 | ), |
| 159 | 158 | )} |
| 160 | ${when(unread.length > 0, () => unreadCard(unread))} | |
| 159 | ${when(unread.length > 0, () => unreadSection(unread))} | |
| 161 | 160 | ${changesSection(crossings, changes, verdicts)}`}`, |
| 162 | 161 | }); |
| 163 | 162 | notePhase(req, "render", performance.now() - rendering); |
| @@ -186,12 +185,20 @@ function summary( | ||
| 186 | 185 | |
| 187 | 186 | const name = (repo: Repo): string => `${repo.ownerName}/${repo.name}`; |
| 188 | 187 | |
| 188 | /** | |
| 189 | * A part of the page as a heading over its rows, never a box: the shared | |
| 190 | * `.card.bare` (docs/design/areas/code.md) keeps the card's markup and hooks | |
| 191 | * and takes its frame off. Its state is a badge's word, so it carries no tone. | |
| 192 | */ | |
| 193 | const section = (body: Html, o: { head: Html; foot?: Html; id?: string }) => | |
| 194 | card(body, { ...o, pad: false, extra: "bare" }); | |
| 195 | ||
| 189 | 196 | /** |
| 190 | 197 | * One app's policy as a table — resources × verbs, a column per identity — |
| 191 | 198 | * with what the running app said last time the forge knocked, and, for |
| 192 | 199 | * whoever can write it, a way to knock now. |
| 193 | 200 | */ |
| 194 | function policyCard( | |
| 201 | function policySection( | |
| 195 | 202 | a: Audited, |
| 196 | 203 | last: ProbeRow | undefined, |
| 197 | 204 | writer: boolean, |
| @@ -204,7 +211,7 @@ function policyCard( | ||
| 204 | 211 | cells.find( |
| 205 | 212 | (c) => c.resource === resource && c.verb === verb && c.as === as, |
| 206 | 213 | ); |
| 207 | return card( | |
| 214 | return section( | |
| 208 | 215 | html`<div class="table-scroll"> |
| 209 | 216 | <table class="table" data-dense> |
| 210 | 217 | <caption class="sr-only"> |
| @@ -242,11 +249,9 @@ function policyCard( | ||
| 242 | 249 | </div> |
| 243 | 250 | ${when(last?.verdict === "mismatch", () => sentences(last!.said))}`, |
| 244 | 251 | { |
| 245 | pad: false, | |
| 246 | 252 | id: `${a.repo.ownerName}-${a.repo.name}`, |
| 247 | ...(last?.verdict === "mismatch" ? { tone: "danger" as const } : {}), | |
| 248 | 253 | foot: probeFoot(a, last, writer), |
| 249 | head: html`${appHead(a.repo, "repo")} | |
| 254 | head: html`${appHead(a.repo)} | |
| 250 | 255 | ${a.standing.open.length === 0 |
| 251 | 256 | ? badge("closed to strangers", { tone: "success" }) |
| 252 | 257 | : badge( |
| @@ -419,10 +424,9 @@ function answer(a: Cell["answer"]): Html { | ||
| 419 | 424 | }); |
| 420 | 425 | } |
| 421 | 426 | |
| 422 | /** A card's head names the app it is about, linked, as a §5 card head. */ | |
| 423 | function appHead(repo: Repo, mark: "repo" | "alert"): Html { | |
| 424 | return html`${icon(mark)} | |
| 425 | <h3><a href="/${repo.ownerName}/${repo.name}">${name(repo)}</a></h3> | |
| 427 | /** A section's head names the app it is about, linked. */ | |
| 428 | function appHead(repo: Repo): Html { | |
| 429 | return html`<h3><a href="/${repo.ownerName}/${repo.name}">${name(repo)}</a></h3> | |
| 426 | 430 | <span class="spacer"></span>`; |
| 427 | 431 | } |
| 428 | 432 | |
| @@ -435,30 +439,29 @@ function sentences(lines: readonly string[]): Html { | ||
| 435 | 439 | </div>`; |
| 436 | 440 | } |
| 437 | 441 | |
| 438 | function unsoundCard(a: Audited): Html { | |
| 442 | function unsoundSection(a: Audited): Html { | |
| 439 | 443 | if (a.standing.kind !== "unsound") return html``; |
| 440 | return card(sentences(a.standing.problems), { | |
| 441 | pad: false, | |
| 442 | tone: "danger", | |
| 444 | return section(sentences(a.standing.problems), { | |
| 443 | 445 | id: `${a.repo.ownerName}-${a.repo.name}`, |
| 444 | head: html`${appHead(a.repo, "alert")} | |
| 446 | head: html`${appHead(a.repo)} | |
| 445 | 447 | ${badge("contract unsound", { tone: "danger" })}`, |
| 446 | 448 | }); |
| 447 | 449 | } |
| 448 | 450 | |
| 449 | function unreadCard(unread: readonly Audited[]): Html { | |
| 450 | return listCard( | |
| 451 | unread.map((a) => | |
| 451 | function unreadSection(unread: readonly Audited[]): Html { | |
| 452 | return section( | |
| 453 | html`<ul class="list"> | |
| 454 | ${unread.map((a) => | |
| 452 | 455 | row({ |
| 453 | 456 | href: `/${a.repo.ownerName}/${a.repo.name}`, |
| 454 | 457 | title: name(a.repo), |
| 455 | 458 | lead: icon("repo"), |
| 456 | 459 | nav: true, |
| 457 | 460 | }), |
| 458 | ), | |
| 461 | )} | |
| 462 | </ul>`, | |
| 459 | 463 | { |
| 460 | head: html`${icon("eye")} | |
| 461 | <h3>Unread</h3>`, | |
| 464 | head: html`<h3>Unread</h3>`, | |
| 462 | 465 | foot: html`<span class="muted small"> |
| 463 | 466 | These apps have no <code>contract.json</code>: their doors are written |
| 464 | 467 | by hand, and this page cannot say what they allow. |
| @@ -477,14 +480,14 @@ function changesSection( | ||
| 477 | 480 | if (crossings.length === 0 && changes.length === 0 && verdicts.length === 0) |
| 478 | 481 | return html``; |
| 479 | 482 | return html`<h2 class="mt mb">What changed</h2> |
| 480 | ${when(crossings.length > 0, () => crossingsCard(crossings))} | |
| 481 | ${when(changes.length > 0, () => changesCard(changes))} | |
| 482 | ${when(verdicts.length > 0, () => verdictsCard(verdicts))}`; | |
| 483 | ${when(crossings.length > 0, () => crossingsSection(crossings))} | |
| 484 | ${when(changes.length > 0, () => contractChangesSection(changes))} | |
| 485 | ${when(verdicts.length > 0, () => verdictsSection(verdicts))}`; | |
| 483 | 486 | } |
| 484 | 487 | |
| 485 | 488 | /** Pulls the crew left at the wall: each is a person's decision, waiting. */ |
| 486 | function crossingsCard(crossings: readonly Crossing[]): Html { | |
| 487 | return card( | |
| 489 | function crossingsSection(crossings: readonly Crossing[]): Html { | |
| 490 | return section( | |
| 488 | 491 | html`<ul class="list"> |
| 489 | 492 | ${crossings.map( |
| 490 | 493 | (c) => |
| @@ -512,10 +515,7 @@ function crossingsCard(crossings: readonly Crossing[]): Html { | ||
| 512 | 515 | )} |
| 513 | 516 | </ul>`, |
| 514 | 517 | { |
| 515 | pad: false, | |
| 516 | tone: "attn", | |
| 517 | head: html`${icon("shield")} | |
| 518 | <h3> | |
| 518 | head: html`<h3> | |
| 519 | 519 | ${count(crossings.length, "pull waits", "pulls wait")} at the wall |
| 520 | 520 | </h3>`, |
| 521 | 521 | }, |
| @@ -523,8 +523,8 @@ function crossingsCard(crossings: readonly Crossing[]): Html { | ||
| 523 | 523 | } |
| 524 | 524 | |
| 525 | 525 | /** Contract commits, newest first, each with what it did to the cells. */ |
| 526 | function changesCard(changes: readonly ContractChange[]): Html { | |
| 527 | return card( | |
| 526 | function contractChangesSection(changes: readonly ContractChange[]): Html { | |
| 527 | return section( | |
| 528 | 528 | html`<ul class="list"> |
| 529 | 529 | ${changes.map((c) => { |
| 530 | 530 | const what = c.removed |
| @@ -577,9 +577,7 @@ function changesCard(changes: readonly ContractChange[]): Html { | ||
| 577 | 577 | })} |
| 578 | 578 | </ul>`, |
| 579 | 579 | { |
| 580 | pad: false, | |
| 581 | head: html`${icon("history")} | |
| 582 | <h3> | |
| 580 | head: html`<h3> | |
| 583 | 581 | ${count(changes.length, "contract change", "contract changes")} |
| 584 | 582 | </h3>`, |
| 585 | 583 | }, |
| @@ -587,7 +585,7 @@ function changesCard(changes: readonly ContractChange[]): Html { | ||
| 587 | 585 | } |
| 588 | 586 | |
| 589 | 587 | /** Where an app's doors started or stopped agreeing with its table. */ |
| 590 | function verdictsCard(verdicts: readonly VerdictChange[]): Html { | |
| 588 | function verdictsSection(verdicts: readonly VerdictChange[]): Html { | |
| 591 | 589 | const word = (v: VerdictChange["to"]): string => |
| 592 | 590 | v === "ok" |
| 593 | 591 | ? "obeys its table" |
| @@ -596,7 +594,7 @@ function verdictsCard(verdicts: readonly VerdictChange[]): Html { | ||
| 596 | 594 | : v === "down" |
| 597 | 595 | ? "was not running" |
| 598 | 596 | : "had no door to knock on"; |
| 599 | return card( | |
| 597 | return section( | |
| 600 | 598 | html`<ul class="list"> |
| 601 | 599 | ${verdicts.map( |
| 602 | 600 | (v) => |
| @@ -627,9 +625,7 @@ function verdictsCard(verdicts: readonly VerdictChange[]): Html { | ||
| 627 | 625 | )} |
| 628 | 626 | </ul>`, |
| 629 | 627 | { |
| 630 | pad: false, | |
| 631 | head: html`${icon("activity")} | |
| 632 | <h3>Doors, over time</h3>`, | |
| 628 | head: html`<h3>Doors, over time</h3>`, | |
| 633 | 629 | }, |
| 634 | 630 | ); |
| 635 | 631 | } |
test/audit.test.ts +15 −2
| @@ -207,13 +207,26 @@ describe("the audit reads every app's policy from git", () => { | ||
| 207 | 207 | expect(body).toContain("Unread"); |
| 208 | 208 | // The table's cells, as the scaffold's contract says. |
| 209 | 209 | expect(body).toMatch(/data-answer="own"/); |
| 210 | // …each answer a §5 badge, in a §5 table, under a §5 card head. | |
| 210 | // …each answer a §5 badge, in a §5 table, under the app's heading. | |
| 211 | 211 | expect(body).toMatch( |
| 212 | 212 | /<td data-answer="own">\s*<span\s+class="badge"\s+data-variant="secondary"\s*>own<\/span/, |
| 213 | 213 | ); |
| 214 | 214 | expect(body).toContain('<table class="table" data-dense>'); |
| 215 | 215 | expect(body).toMatch( |
| 216 | /<div class="card-head">[\s\S]*?<h3><a href="\/alice\/notes">alice\/notes<\/a><\/h3>/, | |
| 216 | /<div class="card-head"><h3><a href="\/alice\/notes">alice\/notes<\/a><\/h3>/, | |
| 217 | ); | |
| 218 | // Rows, not boxes (#9710): each app's standing is a heading over its | |
| 219 | // rows on the page's one background — a bare card, no frame, no tone, | |
| 220 | // no icon before the name — and nothing on the page is a boxed card. | |
| 221 | for (const app of ["alice-notes", "alice-broken", "alice-secret"]) | |
| 222 | expect(body).toMatch( | |
| 223 | new RegExp(`<div\\s+class="card bare"\\s+id="${app}"\\s*>`), | |
| 224 | ); | |
| 225 | const main = body.slice(body.indexOf("<h1>Audit</h1>")); | |
| 226 | expect(main).not.toMatch(/class="card"/); | |
| 227 | expect(main).not.toMatch(/class="card[^"]*"[^>]*data-tone=/); | |
| 228 | expect(main).toMatch( | |
| 229 | /<div class="card-head"><h3>Unread<\/h3><\/div>\s*<ul class="list">/, | |
| 217 | 230 | ); |
| 218 | 231 | // The unread apps are §5 rows. |
| 219 | 232 | expect(body).toMatch(/<a href="\/alice\/bare"\s+class="row"/); |