Public: anyone can read this; the crew can change it.
A commit's head and body draw §5 components on the commit and report pages #4742
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4585-a-commit-s-head-and-body-draw-5-componen → main 18 commits fork 5 threads · 5 unresolved
8 files
changed +75
−17
docs/design/sections/5-components.md +1 −1
| @@ -44,7 +44,7 @@ specification. | ||
| 44 | 44 | | **empty state** `empty()` | `.card.empty`, title `.empty-title` | — | icon, one title, one sentence, **exactly one** action | the same three lines, centered | |
| 45 | 45 | | **diff file** | `.diff-file` | `data-status`, `<details open>`, `.diff-viewed` | collapsed, viewed | a foldable file section | |
| 46 | 46 | | **code block** | `.code` | `:target` / `[data-hl]` line highlight | — | a numbered gutter | |
| 47 | | **prose** | `.prose` | — | 15px, 72ch measure | wrapped markdown | | |
| 47 | | **prose** | `.prose` | `data-text=message`: a `<pre>` of written text that wraps, for commit messages and reports | 15px, 72ch measure; `data-text=message` is 12.5px | wrapped markdown | | |
| 48 | 48 | | **settings shell** `settingsLayout()` | `.settings` (`.settings-nav`, `.settings-main`) | — | sticky nav ≥860px, scroll strip below | a sidebar and a pane | |
| 49 | 49 | | **repo header** `repoHeader()` | `.repo-head` | — | tab counts, Settings only for admins | a title line and a tab row | |
| 50 | 50 | | **where strip** `whereStrip()` (`scope.ts`) | `.where > .where-place` | `data-state=reading\|present\|absent` — accent dot, success dot, faint dot — the dot is the place's `aria-hidden` glyph span, its aside `.muted` | three places, always in this order and these words; a place is a link where one exists, and the third is the pin toggle | `● codeberg.org · ● workbench · ◆ this machine` | |
docs/design/sections/5d-page-local-classes.md −2
| @@ -28,8 +28,6 @@ the same line (#4888). | ||
| 28 | 28 | - `.preview` |
| 29 | 29 | - `.swatch` |
| 30 | 30 | - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, lineage: |
| 31 | - `.commit-body` | |
| 32 | - `.commit-head` | |
| 33 | 31 | - `.filters` |
| 34 | 32 | - `.page-head` |
| 35 | 33 | - `.repo-actions` |
src/web/pages/commits.ts +5 −5
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | empty, |
| 32 | 32 | icon, |
| 33 | 33 | listCard, |
| 34 | messageText, | |
| 34 | 35 | originTag, |
| 35 | 36 | pager, |
| 36 | 37 | sha, |
| @@ -105,12 +106,11 @@ export function renderCommit( | ||
| 105 | 106 | syntax: SyntaxMap, |
| 106 | 107 | ): Html { |
| 107 | 108 | const head: Html = card( |
| 108 | html`<h2>${commit.subject}</h2> | |
| 109 | ${when( | |
| 109 | html`${when( | |
| 110 | 110 | commit.body.trim() !== "", |
| 111 | () => html`<pre class="commit-body">${commit.body}</pre>`, | |
| 111 | () => html`<div class="card-body">${messageText(commit.body)}</div>`, | |
| 112 | 112 | )} |
| 113 | <div class="bar small muted"> | |
| 113 | <div class="card-body bar small muted"> | |
| 114 | 114 | ${commit.login |
| 115 | 115 | ? originTag( |
| 116 | 116 | commit.login, |
| @@ -132,7 +132,7 @@ export function renderCommit( | ||
| 132 | 132 | >${icon("repo")}Browse files</a |
| 133 | 133 | > |
| 134 | 134 | </div>`, |
| 135 | { extra: "commit-head" }, | |
| 135 | { head: html`<h2>${commit.subject}</h2>`, pad: false }, | |
| 136 | 136 | ); |
| 137 | 137 | return html`<div class="mt">${head}</div> |
| 138 | 138 | <div class="mt"> |
src/web/pages/reports.ts +2 −2
| @@ -40,7 +40,7 @@ import { | ||
| 40 | 40 | import { field, readForm } from "../form.ts"; |
| 41 | 41 | import { html, when, type Html } from "../html.ts"; |
| 42 | 42 | import { flashTo, page } from "../layout.ts"; |
| 43 | import { btn, card, empty, field as formField, time } from "../ui.ts"; | |
| 43 | import { btn, card, empty, field as formField, messageText, time } from "../ui.ts"; | |
| 44 | 44 | |
| 45 | 45 | /** The repository, when it takes reports: public, and so seen by anyone. */ |
| 46 | 46 | function publicRepo(ctx: Ctx): Repo | undefined { |
| @@ -220,7 +220,7 @@ function reportCard(base: string, report: Report): Html { | ||
| 220 | 220 | html`<p><strong>${report.title}</strong></p> |
| 221 | 221 | ${when( |
| 222 | 222 | report.body !== "", |
| 223 | () => html`<pre class="commit-body">${report.body}</pre>`, | |
| 223 | () => messageText(report.body), | |
| 224 | 224 | )} |
| 225 | 225 | ${when(report.page, (p) => html`<p class="hint">Sent from ${p}</p>`)} |
| 226 | 226 | <form |
src/web/style/content.ts +7 −7
| @@ -156,7 +156,6 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 156 | 156 | |
| 157 | 157 | .repo-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; } |
| 158 | 158 | |
| 159 | ||
| 160 | 159 | .filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; } |
| 161 | 160 | .filters form { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| 162 | 161 | .filters .search { min-width: 180px; flex: 1; max-width: 320px; } |
| @@ -169,12 +168,6 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 169 | 168 | .bar .seg { max-width: 100%; overflow-x: auto; scrollbar-width: none; } |
| 170 | 169 | @media (max-width: 420px) { .bar .seg > a { padding: 0 8px; } } |
| 171 | 170 | |
| 172 | .commit-head .card-body { padding: 14px 16px; } | |
| 173 | .commit-body { | |
| 174 | white-space: pre-wrap; font-family: var(--mono); font-size: 12.5px; | |
| 175 | color: var(--muted-fg); margin: 8px 0 4px; | |
| 176 | } | |
| 177 | ||
| 178 | 171 | .direction-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; color: var(--muted-fg); margin: 6px 0 0; } |
| 179 | 172 | |
| 180 | 173 | .chart-box { color: var(--accent); aspect-ratio: 720 / 260; max-width: 720px; } |
| @@ -321,6 +314,13 @@ form.inline { display: inline; margin-left: auto; } | ||
| 321 | 314 | border-radius: var(--radius); max-width: none; |
| 322 | 315 | padding: 12px 14px; overflow-x: auto; margin: 14px 0; scrollbar-width: thin; |
| 323 | 316 | } |
| 317 | /* Written text kept as typed (a commit message, a report): plain muted mono, wraps, never scrolls sideways. */ | |
| 318 | .prose[data-text="message"] { padding: 0; margin: 8px 0 4px; font-size: 12.5px; line-height: 1.5; } | |
| 319 | .prose[data-text="message"] pre { | |
| 320 | background: none; border: 0; border-radius: 0; padding: 0; margin: 0; | |
| 321 | font-family: var(--mono); color: var(--muted-fg); | |
| 322 | white-space: pre-wrap; overflow-wrap: anywhere; overflow: visible; | |
| 323 | } | |
| 324 | 324 | .prose code { background: var(--muted); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; } |
| 325 | 325 | .prose pre code { background: none; border: none; padding: 0; } |
| 326 | 326 | .prose blockquote { |
src/web/ui.ts +5
| @@ -885,6 +885,11 @@ export function card(body: Html, o: CardOptions = {}): Html { | ||
| 885 | 885 | </div>`; |
| 886 | 886 | } |
| 887 | 887 | |
| 888 | /** Written text kept as typed (a commit message, a report body): §5's prose, wrapped. */ | |
| 889 | export function messageText(text: string): Html { | |
| 890 | return html`<div class="prose" data-text="message"><pre>${text}</pre></div>`; | |
| 891 | } | |
| 892 | ||
| 888 | 893 | /** A card wrapping a `<ul class="list">` — the shape every listing takes. */ |
| 889 | 894 | export function listCard(rows: Html[], o: CardOptions = {}): Html { |
| 890 | 895 | return card( |
test/commit-page-timing.test.ts +37
| @@ -8,11 +8,14 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 8 | 8 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 9 | 9 | import { adminCookie } from "./helpers/admin.ts"; |
| 10 | 10 | |
| 11 | const LONG = "x".repeat(300); | |
| 12 | ||
| 11 | 13 | let dataDir = ""; |
| 12 | 14 | let work = ""; |
| 13 | 15 | let app: App; |
| 14 | 16 | let servers: Servers; |
| 15 | 17 | let oid = ""; |
| 18 | let bodyOid = ""; | |
| 16 | 19 | |
| 17 | 20 | const env = gitClientEnv({ |
| 18 | 21 | GIT_AUTHOR_NAME: "Ada", |
| @@ -68,6 +71,11 @@ beforeAll(async () => { | ||
| 68 | 71 | await sh(["git", "commit", "-m", "add a"], dir); |
| 69 | 72 | await sh(["git", "push", "origin", "main"], dir); |
| 70 | 73 | oid = await sh(["git", "rev-parse", "HEAD"], dir); |
| 74 | await Bun.write(join(dir, "b.ts"), "export const b = 2;\n"); | |
| 75 | await sh(["git", "add", "."], dir); | |
| 76 | await sh(["git", "commit", "-m", "add b", "-m", "Why b <exists>:\n because a needs it\n" + LONG], dir); | |
| 77 | await sh(["git", "push", "origin", "main"], dir); | |
| 78 | bodyOid = await sh(["git", "rev-parse", "HEAD"], dir); | |
| 71 | 79 | }); |
| 72 | 80 | |
| 73 | 81 | afterAll(async () => { |
| @@ -95,3 +103,32 @@ test("a commit's head draws §5's bar, not .issue-meta", async () => { | ||
| 95 | 103 | expect(body).toContain("Browse files"); |
| 96 | 104 | expect(body).toContain(oid.slice(0, 7)); |
| 97 | 105 | }); |
| 106 | ||
| 107 | test("a commit's head and body draw §5's card-head and prose, not commit-head/commit-body", async () => { | |
| 108 | const body = await (await fetch(`${servers.url}/ada/engine/commit/${oid}`)).text(); | |
| 109 | expect(body).not.toContain("commit-head"); | |
| 110 | expect(body).not.toContain("commit-body"); | |
| 111 | expect(body).toContain('<div class="card-head"><h2>add a</h2></div>'); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a commit with a body shows it, escaped, inside the prose message div", async () => { | |
| 115 | const page = await (await fetch(`${servers.url}/ada/engine/commit/${bodyOid}`)).text(); | |
| 116 | expect(page).toContain( | |
| 117 | `<div class="prose" data-text="message"><pre>Why b <exists>:\n because a needs it\n${LONG}</pre></div>`, | |
| 118 | ); | |
| 119 | const bare = await (await fetch(`${servers.url}/ada/engine/commit/${oid}`)).text(); | |
| 120 | expect(bare).not.toContain('data-text="message"'); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the message rule drops the code box and padding, and wraps a long line instead of scrolling", async () => { | |
| 124 | const page = await (await fetch(`${servers.url}/ada/engine/commit/${oid}`)).text(); | |
| 125 | const href = page.match(/href="(\/assets\/[^"]+\.css)"/)?.[1] ?? ""; | |
| 126 | const css = await (await fetch(`${servers.url}${href}`)).text(); | |
| 127 | const pre = css.match(/\.prose\[data-text="message"\] pre\s*\{[^}]*\}/)?.[0] ?? ""; | |
| 128 | expect(pre).toMatch(/background:\s*none/); | |
| 129 | expect(pre).toMatch(/border:\s*0/); | |
| 130 | expect(pre).toMatch(/padding:\s*0/); | |
| 131 | expect(pre).toMatch(/white-space:\s*pre-wrap/); | |
| 132 | expect(pre).toMatch(/overflow-wrap:\s*anywhere/); | |
| 133 | expect(css).toMatch(/\.prose\[data-text="message"\]\s*\{[^}]*padding:\s*0/); | |
| 134 | }); |
test/reports.test.ts +18
| @@ -408,6 +408,24 @@ describe("reports from outside", () => { | ||
| 408 | 408 | ).toBe(1); |
| 409 | 409 | }); |
| 410 | 410 | |
| 411 | test("a report's body draws in the prose message div, escaped; a report without one draws none", async () => { | |
| 412 | await report("alice/game", { title: "tall menu", body: "Menu <b>breaks</b>\n on phones" }); | |
| 413 | await report("alice/game", { title: "silent bug", body: "" }); | |
| 414 | const withBody = (await waiting("tall menu"))!; | |
| 415 | const without = (await waiting("silent bug"))!; | |
| 416 | const page = await (await as(aliceToken, "/alice/game/reports")).text(); | |
| 417 | expect(page).not.toContain("commit-body"); | |
| 418 | expect(page).toContain( | |
| 419 | '<div class="prose" data-text="message"><pre>Menu <b>breaks</b>\n on phones</pre></div>', | |
| 420 | ); | |
| 421 | // The card of the bodiless report, from its opening to its accept form. | |
| 422 | const end = page.indexOf(`id="accept-${without}"`); | |
| 423 | const card = page.slice(page.lastIndexOf('class="card', end), end); | |
| 424 | expect(card).toContain("silent bug"); | |
| 425 | expect(card).not.toContain('data-text="message"'); | |
| 426 | expect(page.indexOf(`id="accept-${withBody}"`)).toBeGreaterThan(-1); | |
| 427 | }); | |
| 428 | ||
| 411 | 429 | test("the reports page offers the title to change, and the form opens the issue marked", async () => { |
| 412 | 430 | await report("alice/game", { title: "menu bad", body: "On my phone." }); |
| 413 | 431 | const id = (await waiting("menu bad"))!; |
new above
offline
Bare
<div class="prose" data-text="message"><pre>is duplicated here and in reports.ts; one small helper (e.g. in ui.ts) would keep the markup in one place. Also the test only asserts the CSS rule text exists, not that the page shows the body in it; add an assertion that a commit with a body contains the prose div.