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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9648-the-family-page-speaks-the-home-page-s-l → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/9648-the-family-page-speaks-the-home-page-s-l was merged into main as d1f12a4.

Review

Sign in to review this change.
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";
1616 import { pageHead } from "../chrome.ts";
1717 import { html, type Html } from "../html.ts";
1818 import { page } from "../layout.ts";
19 import { badge, card, row, time } from "../ui.ts";
19 import { badge, row } from "../ui.ts";
2020
2121 interface Node {
2222 readonly label: string;
@@ -31,25 +31,16 @@ const plural = (n: number, one: string, many = `${one}s`) =>
3131 `${n} ${n === 1 ? one : many}`;
3232
3333 function standingNode(c: FamilyChild): Node {
34 // One plain line: how it follows and how far behind. The rest is on /forges.
3435 const meta: Html[] = [];
3536 if (c.state === "unheard") meta.push(html`has not reported yet`);
3637 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)
4141 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`);
4842 }
49 const follow = followLine(c);
50 if (follow !== null) meta.push(html`<span data-follow>${follow}</span>`);
5143 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>`);
5344 return {
5445 label: c.name,
5546 href: c.url,
@@ -99,7 +90,7 @@ export async function familyPage(
9990 const here: Node = {
10091 label: view.here.host ?? "this forge",
10192 href: null,
102 meta: [html`version ${view.here.version}`],
93 meta: [],
10394 badge: null,
10495 here: true,
10596 children: kids.items.map(standingNode),
@@ -126,22 +117,11 @@ export async function familyPage(
126117 ${pageHead("Family", {
127118 sub: "The forge this one came from, and the forges it sent. Children report in; nothing here reaches into one.",
128119 })}
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}
145125 </div>`,
146126 });
147127 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; }
441441 .row-children { list-style: none; margin: 0 0 0 22px; padding: 0; border-left: 1px solid var(--border); }
442442 .row-children > li { border-top: 1px solid var(--border); }
443443 @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; }
444449 /* A row's look thumbs (/review): inset like a row, side by side at any width; the link's hit area grows to 44px. */
445450 .row-children > [data-look] { padding: 8px 14px; }
446451 [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", () => {
7171 });
7272
7373 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"');
7575 const rule = content.match(/(?:^|\n)([^\n{]*\[data-family\][^\n{]*)\{([^}]*)\}/);
7676 expect(rule).not.toBeNull();
7777 expect(rule![1]).toMatch(/(?:^|,)\s*\[data-family\]\s*(?:,|$)/);
7878 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/);
8080 });
8181 });
8282
test/family-heartbeat.test.ts +13 −17
@@ -165,8 +165,8 @@ describe("family heartbeats", () => {
165165 expect((await post(body, sign(key, body))).status).toBe(404);
166166 const html = await family(adminCookie);
167167 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");
170170 expect(html).toMatch(/\d+ releases? behind/);
171171 const stored = await readFile(join(dir, "beats", "maria.json"), "utf8");
172172 expect(Object.keys(JSON.parse(stored).beat).sort()).toEqual([
@@ -309,7 +309,10 @@ describe("family heartbeats", () => {
309309 expect(page).toContain("you are here");
310310 expect(page).toContain('aria-label="Forges, by who sent whom"');
311311 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"');
313316 // The tree stays a tree: what this forge sent is a list inside this forge's row.
314317 expect(page).toContain('<ul class="row-children">');
315318 expect(page.indexOf('aria-current="true"')).toBeLessThan(page.indexOf("row-children"));
@@ -360,21 +363,14 @@ describe("family heartbeats", () => {
360363 expect(await sendBeat(parent, async () => new Response("{}"))).toBe(false);
361364 });
362365
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 () => {
364367 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");
378374 });
379375
380376 test("a beat may say how the child follows: mode, commits behind, conflict", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs