Public: anyone can read this; the crew can change it.
The repository page's file listing, README and strips follow the opening principles #9651
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9634-the-repository-page-s-file-listing-readm → main 3 commits fork 5 threads · 5 unresolved
7 files
changed +57
−26
docs/design/areas/code.md +2
| @@ -6,3 +6,5 @@ | ||
| 6 | 6 | `.ex-more` also closes a windowed directory page and the side Files column: the "Show all N entries" link to `?all=1`. |
| 7 | 7 | |
| 8 | 8 | - `.rail-facts`, `.rail-fact` — the facts in a repository's About rail (`aboutCard` in `src/web/pages/repo.ts`): a `dl` of label-over-value pairs, 12px apart, no rules between them, values wrapping anywhere. The rail is narrower than `.status-row`'s 168px label column, so it stacks instead. |
| 9 | ||
| 10 | - `.card.bare` — a card with its frame taken off (`card({ extra: "bare" })`): no border, shadow, fill or padding, no rules between rows, head and foot as plain lines. The repository page's file listing, README, Following panel and fork line use it; the markup, and so every hook, stays the same. |
src/web/pages/following.ts +2
| @@ -229,6 +229,7 @@ export function followingCard( | ||
| 229 | 229 | ctx: Ctx, |
| 230 | 230 | repo: Repo, |
| 231 | 231 | following: Following, |
| 232 | o: { bare?: boolean } = {}, | |
| 232 | 233 | ): Html { |
| 233 | 234 | const { relation, converge } = following; |
| 234 | 235 | const parent = relation.parent; |
| @@ -389,6 +390,7 @@ export function followingCard( | ||
| 389 | 390 | )}`, |
| 390 | 391 | { |
| 391 | 392 | id: "following", |
| 393 | ...(o.bare === true ? { extra: "bare" } : {}), | |
| 392 | 394 | head, |
| 393 | 395 | ...(actions.length > 0 ? { foot: html`${actions}` } : {}), |
| 394 | 396 | pad: false, |
src/web/pages/repo.ts +26 −25
| @@ -97,6 +97,7 @@ import { | ||
| 97 | 97 | tokenCount, |
| 98 | 98 | icon, |
| 99 | 99 | listCard, |
| 100 | type CardOptions, | |
| 100 | 101 | row, |
| 101 | 102 | seg, |
| 102 | 103 | time, |
| @@ -274,29 +275,26 @@ function forkBar(ctx: Ctx, repo: Repo): Html { | ||
| 274 | 275 | const canForge = ctx.auth.user?.kind === "user"; |
| 275 | 276 | const forkCount = forksFor(ctx, repo).length; |
| 276 | 277 | if (!canFork && !canForge && forkCount === 0) return html``; |
| 277 | return html`<div class="mt"> | |
| 278 | ${listCard([ | |
| 279 | row({ | |
| 280 | lead: icon("fork"), | |
| 281 | title: | |
| 282 | forkCount > 0 | |
| 283 | ? html`<a href="${repoBase(repo)}/forks" | |
| 284 | >${badge(`${forkCount} fork${forkCount === 1 ? "" : "s"}`)}</a | |
| 285 | >` | |
| 286 | : "Forks", | |
| 287 | ...(guest && canFork ? { meta: html`${HELP_BUILD_NEXT}` } : {}), | |
| 288 | trail: html`${canFork | |
| 289 | ? html`<form method="post" action="${repoBase(repo)}/fork"> | |
| 290 | ${btn(guest ? HELP_BUILD : "Fork", { size: "sm", icon: "fork" })} | |
| 291 | </form>` | |
| 292 | : html``}${canForge | |
| 293 | ? html`<form method="post" action="${repoBase(repo)}/forge"> | |
| 294 | ${btn("Forge it", { size: "sm", icon: "rocket", variant: "outline" })} | |
| 295 | </form>` | |
| 296 | : html``}`, | |
| 297 | }), | |
| 298 | ])} | |
| 299 | </div>`; | |
| 278 | const forks = row({ | |
| 279 | lead: icon("fork"), | |
| 280 | title: | |
| 281 | forkCount > 0 | |
| 282 | ? html`<a href="${repoBase(repo)}/forks" | |
| 283 | >${badge(`${forkCount} fork${forkCount === 1 ? "" : "s"}`)}</a | |
| 284 | >` | |
| 285 | : "Forks", | |
| 286 | ...(guest && canFork ? { meta: html`${HELP_BUILD_NEXT}` } : {}), | |
| 287 | trail: html`${canFork | |
| 288 | ? html`<form method="post" action="${repoBase(repo)}/fork"> | |
| 289 | ${btn(guest ? HELP_BUILD : "Fork", { size: "sm", icon: "fork" })} | |
| 290 | </form>` | |
| 291 | : html``}${canForge | |
| 292 | ? html`<form method="post" action="${repoBase(repo)}/forge"> | |
| 293 | ${btn("Forge it", { size: "sm", icon: "rocket", variant: "outline" })} | |
| 294 | </form>` | |
| 295 | : html``}`, | |
| 296 | }); | |
| 297 | return html`<div class="mt">${listCard([forks], { extra: "bare" })}</div>`; | |
| 300 | 298 | } |
outdated
Wrap as the file does; and no test covers the fork line being bare. |
||
| 301 | 299 | |
| 302 | 300 | // POST /:owner/:name/fork |
| @@ -517,7 +515,9 @@ export function treeListing( | ||
| 517 | 515 | > |
| 518 | 516 | ${total}` |
| 519 | 517 | : total; |
| 520 | return listCard(rows, foot.text.trim() === "" ? {} : { foot }); | |
| 518 | const options: CardOptions = { extra: "bare" }; | |
| 519 | if (foot.text.trim() !== "") options.foot = foot; | |
| 520 | return listCard(rows, options); | |
outdated
Long line; build the options once instead of two literals. |
||
| 521 | 521 | } |
| 522 | 522 | |
| 523 | 523 | /** |
| @@ -661,6 +661,7 @@ export async function readmeFor( | ||
| 661 | 661 | ? html`` |
| 662 | 662 | : html`<p><a href="${`${whole}/${at.split("/").map(encodeURIComponent).join("/")}`}">Read the whole README</a></p>`; |
| 663 | 663 | const made = card(html`<div class="prose">${rendered}${more}</div>`, { |
| 664 | extra: "bare", | |
| 664 | 665 | pad: false, |
| 665 | 666 | head: html`${icon("file")} |
| 666 | 667 | <h2>${readme.name}</h2>`, |
| @@ -1387,7 +1388,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1387 | 1388 | ? contributionBar(repo) |
| 1388 | 1389 | : following === undefined |
| 1389 | 1390 | ? html`${archivedParentLine(ctx, repo)}${forkBar(ctx, repo)}` |
| 1390 | : followingCard(ctx, repo, following)} | |
| 1391 | : followingCard(ctx, repo, following, { bare: true })} | |
| 1391 | 1392 | ${await renderCode(scope, { |
| 1392 | 1393 | refs, |
| 1393 | 1394 | ref, |
src/web/style/components.ts +9
| @@ -374,6 +374,15 @@ a.sha:hover { color: var(--accent); } | ||
| 374 | 374 | .held-pulls { display: flex; flex-wrap: wrap; gap: 0 4px; align-items: center; } |
| 375 | 375 | .held-pulls a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; } |
| 376 | 376 | :is(.stack, .split, .timeline) > .card + .card { margin-top: 0; } |
| 377 | /* A card that is not a box: space, not lines (DESIGN.md §1). Same markup, no frame. */ | |
| 378 | .card.bare { background: none; border: 0; box-shadow: none; border-radius: 0; overflow: visible; } | |
| 379 | .card.bare > .card-head { background: none; border: 0; padding: 0 0 8px; } | |
| 380 | .card.bare > .card-foot { background: none; border: 0; padding: 8px 0 0; } | |
| 381 | .card.bare > .card-body { padding: 0; } | |
| 382 | .card.bare .card-body.stack { padding: 0 0 8px; } | |
| 383 | .card.bare .list > li + li { border-top: 0; } | |
| 384 | .card.bare .prose { padding: 0; } | |
| 385 | .card.bare .row { border-radius: var(--radius); } | |
| 377 | 386 | .card-head { |
| 378 | 387 | display: flex; align-items: center; gap: 8px; flex-wrap: wrap; |
| 379 | 388 | padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--muted); |
test/following-card.test.ts +11 −1
| @@ -61,13 +61,15 @@ afterAll(async () => { | ||
| 61 | 61 | test("a fork's page draws the following panel with card, row and btn", async () => { |
| 62 | 62 | const res = await fetch(`${servers.url}/bob/lib`, { headers: { cookie: "forge_mode=dev" } }); |
| 63 | 63 | const page = await res.text(); |
| 64 | const m = page.match(/<div\s+class="card"\s+id="following"[\s\S]*?<\/ul>/); | |
| 64 | const m = page.match(/<div\s+class="card bare"\s+id="following"[\s\S]*?<\/ul>/); | |
| 65 | 65 | expect(m).not.toBeNull(); |
| 66 | 66 | const panel = m![0]; |
| 67 | 67 | for (const old of ["following-actions", "following-eyebrow", "following-foot", "following-head", "following-sentence"]) |
| 68 | 68 | expect(page).not.toContain(old); |
| 69 | 69 | expect(page).not.toMatch(/class="[^"]*\bfollowing\b[^"]*"/); |
| 70 | 70 | expect(panel).toContain("card-head"); |
| 71 | // The fork line under it is bare too. | |
| 72 | expect(page).toMatch(/class="mt">\s*<div\s+class="card bare"/); | |
| 71 | 73 | expect(panel).toContain('class="badge'); |
| 72 | 74 | expect(panel).toMatch(/<div class="card-body stack">\s*<p>\s*Everything here is up to date with alice\/lib\./); |
| 73 | 75 | expect(panel).toContain('href="/alice/lib"'); |
| @@ -108,3 +110,11 @@ test("the card foot holds the take and propose forms, each a post form with a bt | ||
| 108 | 110 | expect(forms.map((f) => f[1]!.match(/name="do" value="(\w+)"/)![1])).toEqual(["take", "propose"]); |
| 109 | 111 | for (const f of forms) expect(f[1]).toMatch(/<button[^>]*type="submit"[^>]*class="btn|<button[^>]*class="btn[^>]*type="submit"/); |
| 110 | 112 | }, 30_000); |
| 113 | ||
| 114 | test("the studio app page keeps the Following panel boxed", async () => { | |
| 115 | const { followingCard } = await import("../src/web/pages/following.ts"); | |
| 116 | expect(followingCard.length).toBeLessThanOrEqual(4); | |
| 117 | const src = await Bun.file(new URL("../src/web/studio/app.ts", import.meta.url)).text(); | |
Non-blocking: asserting the function arity says nothing about behaviour; remove it. The not.toContain("bare") source check is brittle too; render the studio page instead. |
||
| 118 | expect(src).toContain("followingCard(ctx, repo, following)"); | |
| 119 | expect(src).not.toContain("bare"); | |
| 120 | }); |
test/readme-cache.test.ts +5
| @@ -47,6 +47,11 @@ test("the second render of one blob does no markdown work", async () => { | ||
| 47 | 47 | expect(String(second)).toBe(String(first)); |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | test("the README is a bare card, not a box", async () => { | |
| 51 | const out = String(await readmeFor(sourceOf("# hi"), "main", [entry(OID_A)], "")); | |
| 52 | expect(out).toContain('class="card bare"'); | |
| 53 | }); | |
| 54 | ||
| 50 | 55 | test("a changed README renders anew", async () => { |
| 51 | 56 | await readmeFor(sourceOf(BIG), "main", [entry(OID_A)], ""); |
| 52 | 57 | const before = markdownWorkDone(); |
test/tree-listing.test.ts +2
| @@ -24,6 +24,7 @@ describe("treeListing", () => { | ||
| 24 | 24 | expect(out).toContain('href="/o/r/tree/main"'); // .. |
| 25 | 25 | expect(out).toContain("a<b>.md"); |
| 26 | 26 | expect(out).toContain("row-trail"); |
| 27 | expect(out).toContain('class="card bare"'); | |
outdated
Principle 6 change needs a test that fails without it: assert |
||
| 27 | 28 | expect(out).toContain('class="card-foot"'); |
| 28 | 29 | expect(out).toMatch(/2 files here/); |
| 29 | 30 | }); |
| @@ -31,5 +32,6 @@ describe("treeListing", () => { | ||
| 31 | 32 | test("no files, no foot", () => { |
| 32 | 33 | const none = treeListing("/o/r", "main", "", [entries[0]] as never).text; |
| 33 | 34 | expect(none).not.toContain("card-foot"); |
| 35 | expect(none).toContain('class="card bare"'); | |
| 34 | 36 | }); |
| 35 | 37 | }); |
This card is shared with the studio app page; that page loses its box too. Scope it or state and check it.