Public: anyone can read this; the crew can change it.
The /family page speaks the home page's language, with looks before and after #9695
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9648-the-family-page-speaks-the-home-page-s-l → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +38
−50
docs/design/areas/family.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # Family | |
| 2 | ||
| 3 | `/family` is a tree of forges: a name and one plain line each (how it follows, | |
| 4 | how far behind). It uses the shared list rows with `.list.bare-tree` | |
| 5 | (components.ts): no card, no rules between rows, no vertical rule; children are | |
| 6 | indented by `--tree-indent`. Version, last seen, proposals and placement are | |
| 7 | not on the row; `/forges` carries them. |
src/web/pages/family.ts +11 −31
| @@ -16,7 +16,7 @@ import { MAX_DEPTH } from "../../spawn/heartbeat.ts"; | ||
| 16 | 16 | import { pageHead } from "../chrome.ts"; |
| 17 | 17 | import { html, type Html } from "../html.ts"; |
| 18 | 18 | import { page } from "../layout.ts"; |
| 19 | import { badge, card, row, time } from "../ui.ts"; | |
| 19 | import { badge, row } from "../ui.ts"; | |
| 20 | 20 | |
| 21 | 21 | interface Node { |
| 22 | 22 | readonly label: string; |
| @@ -31,25 +31,16 @@ const plural = (n: number, one: string, many = `${one}s`) => | ||
| 31 | 31 | `${n} ${n === 1 ? one : many}`; |
| 32 | 32 | |
| 33 | 33 | function standingNode(c: FamilyChild): Node { |
| 34 | // One plain line: how it follows and how far behind. The rest is on /forges. | |
| 34 | 35 | const meta: Html[] = []; |
| 35 | 36 | if (c.state === "unheard") meta.push(html`has not reported yet`); |
| 36 | 37 | else { |
| 37 | if (c.lastSeen !== null) | |
| 38 | meta.push(html`last seen ${time(new Date(c.lastSeen).toISOString())}`); | |
| 39 | if (c.version !== null) meta.push(html`version ${c.version}`); | |
| 40 | if (c.behind !== null) | |
| 38 | const follow = followLine(c); | |
| 39 | if (follow !== null) meta.push(html`<span data-follow>${follow}</span>`); | |
| 40 | else if (c.behind !== null) | |
| 41 | 41 | meta.push(html`${c.behind === 0 ? "up to date" : `${plural(c.behind, "release")} behind`}`); |
| 42 | if (c.openProposals !== null) | |
| 43 | meta.push(html`${plural(c.openProposals, "open proposal")}`); | |
| 44 | if (c.severedAt !== null) meta.push(html`follows nothing since ${c.severedAt.slice(0, 10)}`); | |
| 45 | else if (c.forgeTip !== null) meta.push(html`code at <span class="mono">${c.forgeTip.slice(0, 7)}</span>`); | |
| 46 | if (c.ownChildren !== null && c.ownChildren > 0) | |
| 47 | meta.push(html`${plural(c.ownChildren, "forge")} of its own`); | |
| 48 | 42 | } |
| 49 | const follow = followLine(c); | |
| 50 | if (follow !== null) meta.push(html`<span data-follow>${follow}</span>`); | |
| 51 | 43 | if (c.apps !== null) meta.push(html`<span data-apps>${c.apps}</span>`); |
| 52 | if (c.placement !== null) meta.push(html`<span data-placement>${c.placement}</span>`); | |
| 53 | 44 | return { |
| 54 | 45 | label: c.name, |
| 55 | 46 | href: c.url, |
| @@ -99,7 +90,7 @@ export async function familyPage( | ||
| 99 | 90 | const here: Node = { |
| 100 | 91 | label: view.here.host ?? "this forge", |
| 101 | 92 | href: null, |
| 102 | meta: [html`version ${view.here.version}`], | |
| 93 | meta: [], | |
| 103 | 94 | badge: null, |
| 104 | 95 | here: true, |
| 105 | 96 | children: kids.items.map(standingNode), |
| @@ -126,22 +117,11 @@ export async function familyPage( | ||
| 126 | 117 | ${pageHead("Family", { |
| 127 | 118 | sub: "The forge this one came from, and the forges it sent. Children report in; nothing here reaches into one.", |
| 128 | 119 | })} |
| 129 | ${card( | |
| 130 | html`<ul class="list setting-list" aria-label="Forges, by who sent whom"> | |
| 131 | ${treeRow(root, 0)} | |
| 132 | </ul> | |
| 133 | ${kids.more > 0 | |
| 134 | ? html`<p class="muted small card-body"> | |
| 135 | ${plural(kids.more, "more forge")} not shown. | |
| 136 | </p>` | |
| 137 | : null} | |
| 138 | ${alone | |
| 139 | ? html`<p class="muted small card-body"> | |
| 140 | This forge has no parent and has sent none${owns ? "" : " that you can see"}. | |
| 141 | </p>` | |
| 142 | : null}`, | |
| 143 | { pad: false }, | |
| 144 | )} | |
| 120 | <ul class="list bare-tree" aria-label="Forges, by who sent whom"> | |
| 121 | ${treeRow(root, 0)} | |
| 122 | </ul> | |
| 123 | ${kids.more > 0 ? html`<p class="muted small">${plural(kids.more, "more forge")} not shown.</p>` : null} | |
| 124 | ${alone ? html`<p class="muted small">No parent, and none sent.</p>` : null} | |
| 145 | 125 | </div>`, |
| 146 | 126 | }); |
| 147 | 127 | notePhase(ctx.request, "render", performance.now() - rendering); |
src/web/style/components.ts +5
| @@ -441,6 +441,11 @@ a.row:hover, .row[data-nav]:hover { background: var(--muted); color: inherit; } | ||
| 441 | 441 | .row-children { list-style: none; margin: 0 0 0 22px; padding: 0; border-left: 1px solid var(--border); } |
| 442 | 442 | .row-children > li { border-top: 1px solid var(--border); } |
| 443 | 443 | @media (max-width: 719px) { .row-children { margin-left: 12px; } } |
| 444 | /* A bare tree (/family, docs/design/areas/family.md): names and a line each; indentation is the only structure. */ | |
| 445 | .list.bare-tree > li, .list.bare-tree .row-children > li { border: 0; } | |
| 446 | .list.bare-tree .row-children { border-left: 0; margin-left: var(--tree-indent, 20px); } | |
| 447 | .list.bare-tree .row { padding-top: 10px; padding-bottom: 10px; } | |
| 448 | .list.bare-tree .row-title, .list.bare-tree .row-meta { white-space: normal; overflow: visible; overflow-wrap: anywhere; } | |
| 444 | 449 | /* A row's look thumbs (/review): inset like a row, side by side at any width; the link's hit area grows to 44px. */ |
| 445 | 450 | .row-children > [data-look] { padding: 8px 14px; } |
| 446 | 451 | [data-look] .looks { flex-wrap: nowrap; gap: 10px; align-items: center; } |
test/family-fit.test.ts +2 −2
| @@ -71,12 +71,12 @@ describe("the family page at 390 px", () => { | ||
| 71 | 71 | }); |
| 72 | 72 | |
| 73 | 73 | test("the tree wraps its long names instead of truncating them", () => { |
| 74 | expect(page).toContain('class="list setting-list"'); | |
| 74 | expect(page).toContain('class="list bare-tree"'); | |
| 75 | 75 | const rule = content.match(/(?:^|\n)([^\n{]*\[data-family\][^\n{]*)\{([^}]*)\}/); |
| 76 | 76 | expect(rule).not.toBeNull(); |
| 77 | 77 | expect(rule![1]).toMatch(/(?:^|,)\s*\[data-family\]\s*(?:,|$)/); |
| 78 | 78 | expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/); |
| 79 | expect(components).toMatch(/\.setting-list \.row-title[^{]*\{[^}]*overflow-wrap:\s*anywhere/); | |
| 79 | expect(components).toMatch(/\.bare-tree \.row-title[^{]*\{[^}]*overflow-wrap:\s*anywhere/); | |
| 80 | 80 | }); |
| 81 | 81 | }); |
| 82 | 82 |
test/family-heartbeat.test.ts +13 −17
| @@ -165,8 +165,8 @@ describe("family heartbeats", () => { | ||
| 165 | 165 | expect((await post(body, sign(key, body))).status).toBe(404); |
| 166 | 166 | const html = await family(adminCookie); |
| 167 | 167 | expect(html).toContain("reporting"); |
| 168 | expect(html).toContain("version 0.7.114"); | |
| 169 | expect(html).toContain("3 open proposals"); | |
| 168 | expect(html).not.toContain("version 0.7.114"); | |
| 169 | expect(html).not.toContain("open proposals"); | |
| 170 | 170 | expect(html).toMatch(/\d+ releases? behind/); |
| 171 | 171 | const stored = await readFile(join(dir, "beats", "maria.json"), "utf8"); |
| 172 | 172 | expect(Object.keys(JSON.parse(stored).beat).sort()).toEqual([ |
| @@ -309,7 +309,10 @@ describe("family heartbeats", () => { | ||
| 309 | 309 | expect(page).toContain("you are here"); |
| 310 | 310 | expect(page).toContain('aria-label="Forges, by who sent whom"'); |
| 311 | 311 | expect(page).toContain('class="row"'); |
| 312 | expect(page).toContain("version "); | |
| 312 | expect(page).not.toContain("version "); | |
| 313 | // Bare: no card wrapper, the tree is a bare list. | |
| 314 | expect(page).not.toMatch(/class="card/); | |
| 315 | expect(page).toContain('<ul class="list bare-tree"'); | |
| 313 | 316 | // The tree stays a tree: what this forge sent is a list inside this forge's row. |
| 314 | 317 | expect(page).toContain('<ul class="row-children">'); |
| 315 | 318 | expect(page.indexOf('aria-current="true"')).toBeLessThan(page.indexOf("row-children")); |
| @@ -360,21 +363,14 @@ describe("family heartbeats", () => { | ||
| 360 | 363 | expect(await sendBeat(parent, async () => new Response("{}"))).toBe(false); |
| 361 | 364 | }); |
| 362 | 365 | |
| 363 | test("the parent's /family says where a child's code is, and when it stopped following", async () => { | |
| 366 | test("the parent's /family keeps the row to the follow line: no code tip, no severed date", async () => { | |
| 364 | 367 | const tip = "0123456789abcdef0123456789abcdef01234567"; |
| 365 | const send = async (o: Record<string, unknown>) => { | |
| 366 | const body = beat({ at: Date.now() + 1000, ...o }); | |
| 367 | expect((await post(body, sign(key, body))).status).toBeLessThan(300); | |
| 368 | }; | |
| 369 | await send({ forgeTip: tip }); | |
| 370 | const before = (await family(adminCookie)).replace(/\s+/g, " "); | |
| 371 | expect(before).toContain("code at"); | |
| 372 | expect(before).toContain("0123456"); | |
| 373 | expect(before).not.toContain("follows nothing since"); | |
| 374 | await send({ forgeTip: tip, severedAt: "2030-02-03T04:05:06.000Z", at: Date.now() + 2000 }); | |
| 375 | const after = (await family(adminCookie)).replace(/\s+/g, " "); | |
| 376 | expect(after).toContain("follows nothing since 2030-02-03"); | |
| 377 | expect(after).not.toContain("code at"); | |
| 368 | const body = beat({ at: Date.now() + 1000, forgeTip: tip, severedAt: "2030-02-03T04:05:06.000Z" }); | |
| 369 | expect((await post(body, sign(key, body))).status).toBeLessThan(300); | |
| 370 | const page = await family(adminCookie); | |
| 371 | expect(page).not.toContain("code at"); | |
| 372 | expect(page).not.toContain("follows nothing since"); | |
| 373 | expect(page).not.toContain("0123456"); | |
| 378 | 374 | }); |
| 379 | 375 | |
| 380 | 376 | test("a beat may say how the child follows: mode, commits behind, conflict", async () => { |