Public: anyone can read this; the crew can change it.
The /audit page, who can do what across every app, speaks the home page's language #9866
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9863-the-audit-page-who-can-do-what-across-ev → main 2 commits fork
4 files
changed +38
−29
docs/design/areas/lists.md +3 −2
| @@ -39,8 +39,9 @@ one-line ellipsis. No fixed width; the page does not scroll sideways. No new cla | ||
| 39 | 39 | |
| 40 | 40 | `/audit` (`auditPage` in `src/web/pages/audit.ts`) has no boxed card. Each app's standing (its policy table with the |
| 41 | 41 | last probe under it, an unsound contract's problems, the Unread rows) and each "What changed" part (pulls at the wall, |
| 42 | contract changes, doors over time) is a `.card.bare`: an `<h3>` over its rows on the page's one background, with no | |
| 43 | frame, no tone and no icon before the heading. The state is a badge's word ("contract unsound", "1 door disobeys", | |
| 42 | contract changes, doors over time) is a `<section class="settings-block">` (#9863), no `card` at all: an `<h2>` (an | |
| 43 | `<h3>` under "What changed") on a `.bar` line over its rows on the page's one background, with no frame, no tone and no | |
| 44 | icon before the heading. The state is a badge's word ("contract unsound", "1 door disobeys", | |
| 44 | 45 | "at the wall"), so dropping the tone loses nothing. The table, the `id` each app section is linked by |
| 45 | 46 | (`/audit#owner-name`) and the probe form do not change. No new class. Pinned by `test/audit.test.ts`. |
| 46 | 47 |
src/web/pages/audit.ts +25 −18
| @@ -46,7 +46,6 @@ import { flashTo, page } from "../layout.ts"; | ||
| 46 | 46 | import { |
| 47 | 47 | badge, |
| 48 | 48 | btn, |
| 49 | card, | |
| 50 | 49 | empty, |
| 51 | 50 | icon, |
| 52 | 51 | row, |
| @@ -138,7 +137,7 @@ export async function auditPage(ctx: Ctx): Promise<Response> { | ||
| 138 | 137 | title: "No apps to audit", |
| 139 | 138 | note: "An app's policy shows here once it has a contract.", |
| 140 | 139 | }) |
| 141 | : html`<h2 class="mb"> | |
| 140 | : html`<p class="mb"> | |
| 142 | 141 | ${summary(sound.length, open, unsound.length, unread.length)} |
| 143 | 142 | ${when(disobeying.length > 0, () => |
| 144 | 143 | badge( |
| @@ -146,7 +145,7 @@ export async function auditPage(ctx: Ctx): Promise<Response> { | ||
| 146 | 145 | { tone: "danger", icon: "alert" }, |
| 147 | 146 | ), |
| 148 | 147 | )} |
| 149 | </h2> | |
| 148 | </p> | |
| 150 | 149 | ${unsound.map(unsoundSection)} |
| 151 | 150 | ${sound.map((a) => |
| 152 | 151 | policySection( |
| @@ -187,11 +186,17 @@ const name = (repo: Repo): string => `${repo.ownerName}/${repo.name}`; | ||
| 187 | 186 | |
| 188 | 187 | /** |
| 189 | 188 | * 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. | |
| 189 | * `.settings-block` `<section>`, its `<h2>` on a `.bar` line (so a badge and | |
| 190 | * a commit sit beside the name), its rows under it on the page's one | |
| 191 | * background, its foot a second `.bar`. Its state is a badge's word, so it | |
| 192 | * carries no tone. | |
| 192 | 193 | */ |
| 193 | const section = (body: Html, o: { head: Html; foot?: Html; id?: string }) => | |
| 194 | card(body, { ...o, pad: false, extra: "bare" }); | |
| 194 | const part = (body: Html, o: { head: Html; foot?: Html; id?: string }) => | |
| 195 | html`<section class="settings-block" ${o.id ? html`id="${o.id}"` : null}> | |
| 196 | <div class="bar mb">${o.head}</div> | |
| 197 | ${body} | |
| 198 | ${o.foot ? html`<div class="bar mt">${o.foot}</div>` : null} | |
| 199 | </section>`; | |
| 195 | 200 | |
| 196 | 201 | /** |
| 197 | 202 | * One app's policy as a table — resources × verbs, a column per identity — |
| @@ -211,7 +216,7 @@ function policySection( | ||
| 211 | 216 | cells.find( |
| 212 | 217 | (c) => c.resource === resource && c.verb === verb && c.as === as, |
| 213 | 218 | ); |
| 214 | return section( | |
| 219 | return part( | |
| 215 | 220 | html`<div class="table-scroll"> |
| 216 | 221 | <table class="table" data-dense> |
| 217 | 222 | <caption class="sr-only"> |
| @@ -426,13 +431,13 @@ function answer(a: Cell["answer"]): Html { | ||
| 426 | 431 | |
| 427 | 432 | /** A section's head names the app it is about, linked. */ |
| 428 | 433 | function appHead(repo: Repo): Html { |
| 429 | return html`<h3><a href="/${repo.ownerName}/${repo.name}">${name(repo)}</a></h3> | |
| 434 | return html`<h2><a href="/${repo.ownerName}/${repo.name}">${name(repo)}</a></h2> | |
| 430 | 435 | <span class="spacer"></span>`; |
| 431 | 436 | } |
| 432 | 437 | |
| 433 | 438 | /** Whole sentences — what is wrong, what a door said — as §5 prose. */ |
| 434 | 439 | function sentences(lines: readonly string[]): Html { |
| 435 | return html`<div class="prose"> | |
| 440 | return html`<div class="prose prose-page"> | |
| 436 | 441 | <ul> |
| 437 | 442 | ${lines.map((s) => html`<li>${s}</li>`)} |
| 438 | 443 | </ul> |
| @@ -441,7 +446,7 @@ function sentences(lines: readonly string[]): Html { | ||
| 441 | 446 | |
| 442 | 447 | function unsoundSection(a: Audited): Html { |
| 443 | 448 | if (a.standing.kind !== "unsound") return html``; |
| 444 | return section(sentences(a.standing.problems), { | |
| 449 | return part(sentences(a.standing.problems), { | |
| 445 | 450 | id: `${a.repo.ownerName}-${a.repo.name}`, |
| 446 | 451 | head: html`${appHead(a.repo)} |
| 447 | 452 | ${badge("contract unsound", { tone: "danger" })}`, |
| @@ -449,7 +454,7 @@ function unsoundSection(a: Audited): Html { | ||
| 449 | 454 | } |
| 450 | 455 | |
| 451 | 456 | function unreadSection(unread: readonly Audited[]): Html { |
| 452 | return section( | |
| 457 | return part( | |
| 453 | 458 | html`<ul class="list"> |
| 454 | 459 | ${unread.map((a) => |
| 455 | 460 | row({ |
| @@ -461,7 +466,7 @@ function unreadSection(unread: readonly Audited[]): Html { | ||
| 461 | 466 | )} |
| 462 | 467 | </ul>`, |
| 463 | 468 | { |
| 464 | head: html`<h3>Unread</h3>`, | |
| 469 | head: html`<h2>Unread</h2>`, | |
| 465 | 470 | foot: html`<span class="muted small"> |
| 466 | 471 | These apps have no <code>contract.json</code>: their doors are written |
| 467 | 472 | by hand, and this page cannot say what they allow. |
| @@ -479,15 +484,17 @@ function changesSection( | ||
| 479 | 484 | ): Html { |
| 480 | 485 | if (crossings.length === 0 && changes.length === 0 && verdicts.length === 0) |
| 481 | 486 | return html``; |
| 482 | return html`<h2 class="mt mb">What changed</h2> | |
| 487 | return html`<section class="settings-block"> | |
| 488 | <h2>What changed</h2> | |
| 483 | 489 | ${when(crossings.length > 0, () => crossingsSection(crossings))} |
| 484 | 490 | ${when(changes.length > 0, () => contractChangesSection(changes))} |
| 485 | ${when(verdicts.length > 0, () => verdictsSection(verdicts))}`; | |
| 491 | ${when(verdicts.length > 0, () => verdictsSection(verdicts))} | |
| 492 | </section>`; | |
| 486 | 493 | } |
| 487 | 494 | |
| 488 | 495 | /** Pulls the crew left at the wall: each is a person's decision, waiting. */ |
| 489 | 496 | function crossingsSection(crossings: readonly Crossing[]): Html { |
| 490 | return section( | |
| 497 | return part( | |
| 491 | 498 | html`<ul class="list"> |
| 492 | 499 | ${crossings.map( |
| 493 | 500 | (c) => |
| @@ -524,7 +531,7 @@ function crossingsSection(crossings: readonly Crossing[]): Html { | ||
| 524 | 531 | |
| 525 | 532 | /** Contract commits, newest first, each with what it did to the cells. */ |
| 526 | 533 | function contractChangesSection(changes: readonly ContractChange[]): Html { |
| 527 | return section( | |
| 534 | return part( | |
| 528 | 535 | html`<ul class="list"> |
| 529 | 536 | ${changes.map((c) => { |
| 530 | 537 | const what = c.removed |
| @@ -594,7 +601,7 @@ function verdictsSection(verdicts: readonly VerdictChange[]): Html { | ||
| 594 | 601 | : v === "down" |
| 595 | 602 | ? "was not running" |
| 596 | 603 | : "had no door to knock on"; |
| 597 | return section( | |
| 604 | return part( | |
| 598 | 605 | html`<ul class="list"> |
| 599 | 606 | ${verdicts.map( |
| 600 | 607 | (v) => |
src/web/style/components.ts +1 −1
| @@ -982,7 +982,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 982 | 982 | /* ---- settings content: status rows, blocks, a segmented control ---- */ |
| 983 | 983 | .settings-block { margin-top: 24px; } |
| 984 | 984 | .settings-block:first-child { margin-top: 0; } |
| 985 | .settings-block > h2, .settings-block > .bar > h2 { font-size: 14px; font-weight: 600; margin: 0; } | |
| 985 | .settings-block > h2, .settings-block > .bar > :is(h2, h3) { font-size: 14px; font-weight: 600; margin: 0; } | |
| 986 | 986 | .settings-block > h2 { margin-bottom: 8px; } |
| 987 | 987 | .settings-block > .bar { gap: 12px; flex-wrap: wrap; } |
| 988 | 988 | .settings-block > p { margin-top: 8px; } |
test/audit.test.ts +9 −8
| @@ -213,20 +213,21 @@ describe("the audit reads every app's policy from git", () => { | ||
| 213 | 213 | ); |
| 214 | 214 | expect(body).toContain('<table class="table" data-dense>'); |
| 215 | 215 | expect(body).toMatch( |
| 216 | /<div class="card-head"><h3><a href="\/alice\/notes">alice\/notes<\/a><\/h3>/, | |
| 216 | /<div class="bar mb"><h2><a href="\/alice\/notes">alice\/notes<\/a><\/h2>/, | |
| 217 | 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. | |
| 218 | // Rows, not boxes (#9710, #9863): each app's standing is a <section> | |
| 219 | // under its <h2>, its rows on the page's one background — no card at | |
| 220 | // all, no tone, no icon before the name. | |
| 221 | 221 | for (const app of ["alice-notes", "alice-broken", "alice-secret"]) |
| 222 | 222 | expect(body).toMatch( |
| 223 | new RegExp(`<div\\s+class="card bare"\\s+id="${app}"\\s*>`), | |
| 223 | new RegExp(`<section\\s+class="settings-block"\\s+id="${app}"\\s*>`), | |
| 224 | 224 | ); |
| 225 | 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=/); | |
| 226 | expect(main).not.toContain('class="card'); | |
| 227 | expect(main).not.toMatch(/data-tone=/); | |
| 228 | // An app without a contract is not hidden: it is a row under "Unread". | |
| 228 | 229 | expect(main).toMatch( |
| 229 | /<div class="card-head"><h3>Unread<\/h3><\/div>\s*<ul class="list">/, | |
| 230 | /<section\s+class="settings-block"\s*>\s*<div class="bar mb"><h2>Unread<\/h2><\/div>\s*<ul class="list">\s*<li[^>]*>\s*<a href="\/alice\/bare"\s+class="row"/, | |
| 230 | 231 | ); |
| 231 | 232 | // The unread apps are §5 rows. |
| 232 | 233 | expect(body).toMatch(/<a href="\/alice\/bare"\s+class="row"/); |