Public: anyone can read this; the crew can change it.
A fork's Following card and its forks lineage speak the home page's language #9912
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9841-a-fork-s-following-card-and-its-forks-li → main 2 commits fork
3 files
changed +48
−40
docs/design/areas/family.md +8
| @@ -5,3 +5,11 @@ how far behind). It uses the shared list rows with `.list.bare-tree` | ||
| 5 | 5 | (components.ts): no card, no rules between rows, no vertical rule; children are |
| 6 | 6 | indented by `--tree-indent`. Version, last seen, proposals and placement are |
| 7 | 7 | not on the row; `/forges` carries them. |
| 8 | ||
| 9 | ## A fork's Following section and its forks lineage | |
| 10 | ||
| 11 | On a repository page, `#following` and the lineage of forks are a heading over | |
| 12 | rows with no card: `section.settings-block` with the heading (the parent, and | |
| 13 | the state as a badge), the sentences in `.stack`, `row`s, and the actions in a | |
| 14 | `.bar` beneath. No new class; the `bare` option of `followingCard` now only | |
| 15 | drops the top margin. |
src/web/pages/following.ts +31 −30
| @@ -47,7 +47,7 @@ import { proposesViaFork } from "../../origins/fork-route.ts"; | ||
| 47 | 47 | import { field, readForm } from "../form.ts"; |
| 48 | 48 | import { html, when, type Html } from "../html.ts"; |
| 49 | 49 | import { flashOrJson } from "../layout.ts"; |
| 50 | import { badge, btn, card, icon, row, type Tone } from "../ui.ts"; | |
| 50 | import { badge, btn, icon, row, type Tone } from "../ui.ts"; | |
| 51 | 51 | import { disconnectedOn } from "./frozen.ts"; |
| 52 | 52 | |
| 53 | 53 | /** |
| @@ -74,20 +74,19 @@ export function lineageCard(lineage: Lineage, hereId: string): Html { | ||
| 74 | 74 | children: n.children.map((c) => node(c, depth + 1)), |
| 75 | 75 | }); |
| 76 | 76 | }; |
| 77 | return card( | |
| 78 | html`<ul class="list" aria-label="Forks, by who follows whom"> | |
| 79 | ${node(lineage.root, 0)} | |
| 80 | </ul> | |
| 81 | ${when( | |
| 82 | lineage.truncated, | |
| 83 | () => | |
| 84 | html`<p class="muted small card-body"> | |
| 85 | A large family: the newest and deepest forks are left out here; each | |
| 86 | fork's own page shows where it stands. | |
| 87 | </p>`, | |
| 88 | )}`, | |
| 89 | { pad: false }, | |
| 90 | ); | |
| 77 | return html`<section class="settings-block"> | |
| 78 | <ul class="list" aria-label="Forks, by who follows whom"> | |
| 79 | ${node(lineage.root, 0)} | |
| 80 | </ul> | |
| 81 | ${when( | |
| 82 | lineage.truncated, | |
| 83 | () => | |
| 84 | html`<p class="muted small"> | |
| 85 | A large family: the newest and deepest forks are left out here; each | |
| 86 | fork's own page shows where it stands. | |
| 87 | </p>`, | |
| 88 | )} | |
| 89 | </section>`; | |
| 91 | 90 | } |
| 92 | 91 | |
| 93 | 92 | export interface Following { |
| @@ -381,21 +380,23 @@ export function followingCard( | ||
| 381 | 380 | title: `${followers === 1 ? "One fork follows" : `${followers} forks follow`} this one.`, |
| 382 | 381 | }), |
| 383 | 382 | ); |
| 384 | return card( | |
| 385 | html`<div class="card-body stack">${prose}</div>${when( | |
| 386 | rows.length > 0, | |
| 387 | () => html`<ul class="list"> | |
| 388 | ${rows} | |
| 389 | </ul>`, | |
| 390 | )}`, | |
| 391 | { | |
| 392 | id: "following", | |
| 393 | ...(o.bare === true ? { extra: "bare" } : {}), | |
| 394 | head, | |
| 395 | ...(actions.length > 0 ? { foot: html`${actions}` } : {}), | |
| 396 | pad: false, | |
| 397 | }, | |
| 398 | ); | |
| 383 | return html`<section | |
| 384 | class="settings-block${o.bare === true ? " bare" : " mt"}" | |
| 385 | id="following" | |
| 386 | > | |
| 387 | <div class="bar"><h2>${head}</h2></div> | |
| 388 | <div class="stack">${prose}</div> | |
| 389 | ${when( | |
| 390 | rows.length > 0, | |
| 391 | () => html`<ul class="list"> | |
| 392 | ${rows} | |
| 393 | </ul>`, | |
| 394 | )} | |
| 395 | ${when( | |
| 396 | actions.length > 0, | |
| 397 | () => html`<div class="bar mt">${actions}</div>`, | |
| 398 | )} | |
| 399 | </section>`; | |
| 399 | 400 | } |
| 400 | 401 | |
| 401 | 402 | /** |
test/following-card.test.ts +9 −10
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | * The "following" card on a fork's page is built by card() (#2891): one | |
| 3 | * `div.card.following#following` with its badge and sentence, not hand-rolled | |
| 2 | * The "following" section on a fork's page (#9841) is a heading over rows, no box: one | |
| 3 | * `section#following` with its badge and sentence, not hand-rolled | |
| 4 | 4 | * markup. |
| 5 | 5 | */ |
| 6 | 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| @@ -61,17 +61,16 @@ 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 bare"\s+id="following"[\s\S]*?<\/ul>/); | |
| 64 | const m = page.match(/<section\s+class="settings-block bare"\s+id="following"[\s\S]*?<\/section>/); | |
| 65 | 65 | expect(m).not.toBeNull(); |
| 66 | 66 | const panel = m![0]; |
| 67 | expect(panel).not.toContain('class="card'); | |
| 67 | 68 | for (const old of ["following-actions", "following-eyebrow", "following-foot", "following-head", "following-sentence"]) |
| 68 | 69 | expect(page).not.toContain(old); |
| 69 | 70 | expect(page).not.toMatch(/class="[^"]*\bfollowing\b[^"]*"/); |
| 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 | expect(panel).toMatch(/<h2>/); | |
| 73 | 72 | expect(panel).toContain('class="badge'); |
| 74 | expect(panel).toMatch(/<div class="card-body stack">\s*<p>\s*Everything here is up to date with alice\/lib\./); | |
| 73 | expect(panel).toMatch(/<div class="stack">\s*<p>\s*Everything here is up to date with alice\/lib\./); | |
| 75 | 74 | expect(panel).toContain('href="/alice/lib"'); |
| 76 | 75 | expect(panel).toContain("following"); |
| 77 | 76 | }, 30_000); |
| @@ -99,10 +98,10 @@ test("the card foot holds the take and propose forms, each a post form with a bt | ||
| 99 | 98 | if (!page.includes('value="take"')) await Bun.sleep(40); |
| 100 | 99 | } |
| 101 | 100 | if (!page.includes('value="take"')) throw new Error(`never came: still waiting while !page.includes('value="take"')`); |
| 102 | const foot = page.match(/<div class="card-foot">([\s\S]*?)<\/div>\s*<\/div>/); | |
| 101 | const foot = page.match(/<div class="bar mt">([\s\S]*?)<\/div>\s*<\/section>/); | |
| 103 | 102 | expect(foot).not.toBeNull(); |
| 104 | 103 | // Prose wraps: sentences and notes are paragraphs, never a (nowrap) row title. |
| 105 | const body = page.match(/id="following"[\s\S]*?<div class="card-body stack">([\s\S]*?)<\/div>/); | |
| 104 | const body = page.match(/id="following"[\s\S]*?<div class="stack">([\s\S]*?)<\/div>/); | |
| 106 | 105 | expect(body).not.toBeNull(); |
| 107 | 106 | expect(body![1]).toContain("<p>"); |
| 108 | 107 | expect(body![1]).not.toContain("row-title"); |
| @@ -111,7 +110,7 @@ test("the card foot holds the take and propose forms, each a post form with a bt | ||
| 111 | 110 | for (const f of forms) expect(f[1]).toMatch(/<button[^>]*type="submit"[^>]*class="btn|<button[^>]*class="btn[^>]*type="submit"/); |
| 112 | 111 | }, 30_000); |
| 113 | 112 | |
| 114 | test("the studio app page keeps the Following panel boxed", async () => { | |
| 113 | test("the studio app page draws the Following section unboxed, without the bare variant", async () => { | |
| 115 | 114 | const { followingCard } = await import("../src/web/pages/following.ts"); |
| 116 | 115 | expect(followingCard.length).toBeLessThanOrEqual(4); |
| 117 | 116 | const src = await Bun.file(new URL("../src/web/studio/app.ts", import.meta.url)).text(); |