Public: anyone can read this; the crew can change it.
An app's policy line draws §5's row and badge, and .policy-line leaves the stylesheet #5710
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4757-an-app-s-policy-line-draws-5-s-row-and-b → main 10 commits fork
4 files
changed +82
−36
docs/design/sections/5d-page-local-classes.md −1
| @@ -53,7 +53,6 @@ the same line (#4888). | ||
| 53 | 53 | - `.filters` |
| 54 | 54 | - `.owner` |
| 55 | 55 | - `.page-head` |
| 56 | - `.policy-line` | |
| 57 | 56 | - `.repo-actions` |
| 58 | 57 | - `.repo-desc` |
| 59 | 58 | - `.slash` |
src/web/studio/app.ts +45 −25
| @@ -39,6 +39,7 @@ import { liveClientJs, liveMount } from "../live.ts"; | ||
| 39 | 39 | import { followingCard, followingOf } from "../pages/following.ts"; |
| 40 | 40 | import { |
| 41 | 41 | avatar, |
| 42 | badge, | |
| 42 | 43 | btn, |
| 43 | 44 | card, |
| 44 | 45 | envPhrase, |
| @@ -159,6 +160,11 @@ function previewLatencyWords( | ||
| 159 | 160 | <span class="muted">· ${split}${latency.n} deploys${failedSuffix}</span>`; |
| 160 | 161 | } |
| 161 | 162 | |
| 163 | /** The policy line's card: `setting-list` lets the title wrap and the trail drop under it on a phone. */ | |
| 164 | function policyLineCard(line: Html): Html { | |
| 165 | return card(html`<ul class="list setting-list">${line}</ul>`, { pad: false }); | |
| 166 | } | |
| 167 | ||
| 162 | 168 | /** |
| 163 | 169 | * One line about the app's policy, when it has one: what it opens to |
| 164 | 170 | * anyone and what the running app said when the forge last knocked, with |
| @@ -174,14 +180,14 @@ async function policyLine( | ||
| 174 | 180 | if (standing.kind === "unread") return html``; |
| 175 | 181 | const href = `/audit#${repo.ownerName}-${repo.name}`; |
| 176 | 182 | if (standing.kind === "unsound") |
| 177 | return html`<p class="policy-line"> | |
| 178 | ${icon("alert")} | |
| 179 | <span | |
| 180 | ><b>Its contract does not read</b>, so an app built on it does not | |
| 181 | boot.</span | |
| 182 | > | |
| 183 | <a href="${href}">See why</a> | |
| 184 | </p>`; | |
| 183 | return policyLineCard( | |
| 184 | row({ | |
| 185 | lead: icon("alert"), | |
| 186 | title: html`<b>Its contract does not read</b>, so an app built on it | |
| 187 | does not boot.`, | |
| 188 | trail: html`<a href="${href}">See why</a>`, | |
| 189 | }), | |
| 190 | ); | |
| 185 | 191 | const last = latestProbes(ctx.app.db, [record.id]).get(record.id); |
| 186 | 192 | const open = standing.open.length; |
| 187 | 193 | const said = |
| @@ -194,23 +200,37 @@ async function policyLine( | ||
| 194 | 200 | : last.verdict === "down" |
| 195 | 201 | ? "was not running when knocked on" |
| 196 | 202 | : "has no door to knock on"; |
| 197 | return html`<p class="policy-line" data-tone="${last?.verdict ?? "none"}"> | |
| 198 | ${icon("shield")} | |
| 199 | <span | |
| 200 | ><b | |
| 201 | >${Object.keys(standing.contract.resources).length === 1 | |
| 202 | ? "One resource" | |
| 203 | : `${Object.keys(standing.contract.resources).length} resources`}</b | |
| 204 | >, | |
| 205 | ${open === 0 | |
| 206 | ? "nothing open to anyone" | |
| 207 | : open === 1 | |
| 208 | ? "one door open to anyone" | |
| 209 | : `${open} doors open to anyone`}; | |
| 210 | ${said}.</span | |
| 211 | > | |
| 212 | <a href="${href}">The table</a> | |
| 213 | </p>`; | |
| 203 | const resources = Object.keys(standing.contract.resources).length; | |
| 204 | const tone = | |
| 205 | last?.verdict === "ok" | |
| 206 | ? "success" | |
| 207 | : last?.verdict === "mismatch" | |
| 208 | ? "danger" | |
| 209 | : "default"; | |
| 210 | const short = | |
| 211 | last === undefined | |
| 212 | ? "not knocked" | |
| 213 | : last.verdict === "ok" | |
| 214 | ? "obeys" | |
| 215 | : last.verdict === "mismatch" | |
| 216 | ? "disobeys" | |
| 217 | : last.verdict === "down" | |
| 218 | ? "down" | |
| 219 | : "no door"; | |
| 220 | return policyLineCard( | |
| 221 | row({ | |
| 222 | lead: icon("shield"), | |
| 223 | title: html`<b>${resources === 1 ? "One resource" : `${resources} resources`}</b>, | |
| 224 | ${open === 0 | |
| 225 | ? "nothing open to anyone" | |
| 226 | : open === 1 | |
| 227 | ? "one door open to anyone" | |
| 228 | : `${open} doors open to anyone`}`, | |
| 229 | meta: html`${said}`, | |
| 230 | trail: html`${badge(short, { tone })} | |
| 231 | <a href="${href}">The table</a>`, | |
| 232 | }), | |
| 233 | ); | |
| 214 | 234 | } |
| 215 | 235 | |
| 216 | 236 | export async function studioApp( |
src/web/style/content.ts −8
| @@ -205,14 +205,6 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 205 | 205 | .fg-s6 { fill: var(--series-6); color: var(--series-6); } |
| 206 | 206 | .fg-s7 { fill: var(--series-7); color: var(--series-7); } |
| 207 | 207 | .restore-command { margin: 8px 0 0; padding: 8px 10px; font-size: 12.5px; white-space: pre-wrap; word-break: break-all; background: var(--muted); border-radius: var(--radius); } |
| 208 | .policy-line { | |
| 209 | display: flex; align-items: center; gap: 8px; flex-wrap: wrap; | |
| 210 | margin: 12px 0 0; font-size: 13px; color: var(--muted-fg); | |
| 211 | } | |
| 212 | .policy-line .ico { color: var(--faint); flex: none; } | |
| 213 | .policy-line b { color: var(--fg); font-weight: 600; } | |
| 214 | .policy-line[data-tone="mismatch"] .ico { color: var(--danger); } | |
| 215 | .policy-line[data-tone="ok"] .ico { color: var(--success); } | |
| 216 | 208 | form.inline { display: inline; margin-left: auto; } |
| 217 | 209 | |
| 218 | 210 | /* The studio's front door: the public apps, live first, as pictures. */ |
test/audit.test.ts +37 −2
| @@ -442,7 +442,8 @@ describe("the audit reads every app's policy from git", () => { | ||
| 442 | 442 | headers: { cookie: `${cookie}; forge_mode=studio` }, |
| 443 | 443 | }) |
| 444 | 444 | ).text(); |
| 445 | expect(notes).toContain('class="policy-line"'); | |
| 445 | expect(notes).not.toContain("policy-line"); | |
| 446 | expect(notes).toContain('class="badge"'); | |
| 446 | 447 | expect(notes).toContain("2 resources"); |
| 447 | 448 | expect(notes).toContain('href="/audit#alice-notes"'); |
| 448 | 449 | // The audit page carries that anchor. |
| @@ -456,6 +457,40 @@ describe("the audit reads every app's policy from git", () => { | ||
| 456 | 457 | headers: { cookie: `${cookie}; forge_mode=studio` }, |
| 457 | 458 | }) |
| 458 | 459 | ).text(); |
| 459 | expect(bare).not.toContain('class="policy-line"'); | |
| 460 | expect(bare).not.toContain("policy-line"); | |
| 461 | expect(bare).not.toContain("The table"); | |
| 462 | }); | |
| 463 | ||
| 464 | test("an app's policy line badges the last verdict: success when it obeys, danger when it does not", async () => { | |
| 465 | const notes = getRepo(app.db, "alice", "notes")!; | |
| 466 | const record = getAppByRepo(app.db, notes.id)!; | |
| 467 | const page = async () => | |
| 468 | ( | |
| 469 | await fetch(`${servers.url}/alice/notes`, { | |
| 470 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 471 | }) | |
| 472 | ).text(); | |
| 473 | const probe = (verdict: "ok" | "mismatch", said: string[]) => | |
| 474 | recordProbe(app.db, record.id, { | |
| 475 | repo: notes, | |
| 476 | commitOid: "c".repeat(40), | |
| 477 | at: "", | |
| 478 | verdict, | |
| 479 | knocks: [], | |
| 480 | said, | |
| 481 | }); | |
| 482 | probe("ok", []); | |
| 483 | const ok = await page(); | |
| 484 | expect(ok).toContain('data-variant="success"'); | |
| 485 | expect(ok).toContain("obeys its table"); | |
| 486 | expect(ok).toContain("setting-list"); | |
| 487 | expect(ok).toMatch(/data-variant="success"\s*>\s*(<svg[\s\S]*?<\/svg>)?obeys</); | |
| 488 | expect(ok).not.toContain("policy-line"); | |
| 489 | probe("mismatch", ["note.delete as anon: the table says no, the app said 404"]); | |
| 490 | const bad = await page(); | |
| 491 | expect(bad).toContain('data-variant="danger"'); | |
| 492 | expect(bad).toContain("one door disobeys its table"); | |
| 493 | expect(bad).toMatch(/data-variant="danger"\s*>\s*(<svg[\s\S]*?<\/svg>)?disobeys</); | |
| 494 | expect(bad).not.toContain("policy-line"); | |
| 460 | 495 | }); |
| 461 | 496 | }); |
new above
offline