Public: anyone can read this; the crew can change it.

The home's KPI row wraps to a second line on a narrow screen instead of scrolling sideways #6103

Merged opened this

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

Merged

scout/forge:scout/6067-the-home-s-kpi-row-wraps-to-a-second-lin → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/6067-the-home-s-kpi-row-wraps-to-a-second-lin was merged into main as 711c286.

Review

Sign in to review this change.
7 files changed +83 −8
docs/design/areas/home.md +1
@@ -2,3 +2,4 @@
22
33 - `.moving` — the living home's two lists (Being built, Just shipped), drawn by `movingRegion` in `src/web/studio/living.ts`: a one-column grid on a phone, two equal columns from 700px. Each list always holds three rows, so a quiet list is as tall as a busy one and nothing below it moves.
44 - `.tell-box` — home's one-line box that tells the crew: a `bar` whose text input takes the free width (`flex: 1 1 14rem`) and wraps the app picker and Tell under it on a phone.
5 - `.kpi-row` — a modifier on `.stats` for the home's figure tiles, not a second layout: it only changes the phone case. `.stats` alone (`minmax(160px, 1fr)`) falls to one column at 360px (two 160px tiles and the gap need 332px of 328px), stacking nine 104px tiles to ~1,100px; under 480px `.kpi-row` is two equal `minmax(0, 1fr)` columns instead. Tile height stays fixed (`.stat`), so nothing shifts.
src/web/studio/kpi_row.ts +1 −1
@@ -95,7 +95,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
9595 const overdue = backupOverdue(k.backupAt, now);
9696 const spendWords = spendCeilingWords(k.spend);
9797 const rate = k.landed / k.landedHours.length;
98 return html`<section class="stats mb" aria-label="The last 24 hours, as of this page load">
98 return html`<section class="stats kpi-row mb" aria-label="The last 24 hours, as of this page load">
9999 ${tile(
100100 "Shipped per hour",
101101 "/board",
src/web/style/components.ts +1
@@ -156,6 +156,7 @@ fieldset.field > .check + .check { margin-top: 8px; }
156156 overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius);
157157 background: var(--card); color: var(--fg); text-decoration: none;
158158 }
159 @media (max-width: 480px) { .kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
159160 a.stat:hover { border-color: var(--border-strong); }
160161 .stat[data-tone="attn"] { border-color: color-mix(in srgb, var(--attn) 40%, var(--border)); }
161162 .stat-label { display: block; height: 18px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-min); line-height: 18px; color: var(--muted-fg); }
test/client-home.test.ts +2 −2
@@ -405,8 +405,8 @@ describe("a client's first screen", () => {
405405 });
406406
407407 test("a client sees no forge-wide figures, lean or whole", async () => {
408 expect(await page(owner)).not.toContain('class="stats mb"');
409 expect(await page(owner, "/?all=1")).not.toContain('class="stats mb"');
408 expect(await page(owner)).not.toContain('class="stats kpi-row mb"');
409 expect(await page(owner, "/?all=1")).not.toContain('class="stats kpi-row mb"');
410410 });
411411
412412 test("queued agents and no running ones read as about to start", async () => {
test/home-cache.test.ts +2 −2
@@ -45,7 +45,7 @@ afterAll(async () => {
4545
4646 test("a warm home reads neither the KPIs nor the panels again, even after a write", async () => {
4747 const a = await home(f.ownerCookie);
48 expect(a).toContain('class="stats mb"');
48 expect(a).toContain('class="stats kpi-row mb"');
4949 expect(a).toContain("<h2>Pin A</h2>");
5050 const kpi = kpiCache(f.app.db);
5151 const panel = panelCache(f.app.db);
@@ -66,7 +66,7 @@ test("the owner's KPIs and panels never render for another viewer, warm or not",
6666 await home(f.ownerCookie);
6767 for (const cookie of [f.peerCookie, undefined]) {
6868 const page = await home(cookie);
69 expect(page).not.toContain('class="stats mb"');
69 expect(page).not.toContain('class="stats kpi-row mb"');
7070 expect(page).not.toContain('class="stat"');
7171 expect(page).not.toContain("Pin A");
7272 }
test/home-kpi-page.test.ts +3 −3
@@ -59,7 +59,7 @@ test("home renders the nine tiles; only an admin sees them", async () => {
5959 view(day, "a", 1900, null);
6060 view(yesterday, "z", 400, 160);
6161 const a = await home(admin);
62 expect(a).toContain('class="stats mb"');
62 expect(a).toContain('class="stats kpi-row mb"');
6363 expect((a.match(/class="stat"/g) ?? []).length).toBe(9);
6464 for (const href of ["/board", "/releases", "/boards", "/settings/crew", "/settings/backups"]) expect(a).toContain(`class="stat" href="${href}"`);
6565 // The tiles are §5 stats (#3982): none of the page-local kpi classes is left on home.
@@ -78,9 +78,9 @@ test("home renders the nine tiles; only an admin sees them", async () => {
7878 // The figures count every repository, so a non-admin sees none.
7979 expect(a).toContain("no forge ceiling");
8080 expect(a).toContain("last 30 days");
81 expect(await home(bob)).not.toContain('class="stats mb"');
81 expect(await home(bob)).not.toContain('class="stats kpi-row mb"');
8282 expect(await home(bob)).not.toContain("last 30 days");
83 expect(await home()).not.toContain('class="stats mb"');
83 expect(await home()).not.toContain('class="stats kpi-row mb"');
8484 expect(await home()).not.toContain("last 30 days");
8585 });
8686
test/kpi-wrap.test.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * The home's KPI row at phone width (#6067). The row is `.stats` (an
3 * auto-fit grid) plus `.kpi-row`; at 360px (328px of content) two 160px
4 * tiles and the gap need 332px, so the grid alone falls to one column and
5 * nine tiles stack to ~1,100px. This lays the real tiles and the real
6 * stylesheet out in headless Chromium, inside a 360px iframe so the media
7 * query sees a phone, and counts the columns the browser actually made.
8 */
9 import { expect, test } from "bun:test";
10 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { findChromium } from "../src/looks/capture.ts";
14 import { kpiRowFor } from "../src/web/studio/kpi_row.ts";
15 import { components } from "../src/web/style/components.ts";
16 import type { Kpis } from "../src/flow/kpis.ts";
17 import { chromiumRuns } from "./helpers/chrome.ts";
outdated

These regexes check CSS text, not behaviour, and would pass with the rule overridden. Assert the column count or tile width at 360px.

18
19 const k = {
20 landedHours: Array(24).fill(0), landed: 0, live: null, medianCheckMs: null,
21 builders: { running: 1, slots: 2 }, spend: { todayUsd: 123456.78, ceilingUsd: null }, backupAt: null,
22 reach: { visitorDays: [1, 2], loadP75: null },
23 } as unknown as Kpis;
24
25 const tiles = kpiRowFor(k, null).toString();
26
27 test("a six-digit value renders inside a tile of the row", () => {
28 expect(tiles).toContain('class="stats kpi-row mb"');
29 expect(tiles).toMatch(/<span class="stat-value num"[^>]*>\$123457<\/span>/);
30 });
31
32 const found = findChromium();
33 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
34
35 /** The distinct x offsets of the tiles' left edges in a frame `width` wide. */
36 function columnsAt(bin: string, width: number): number {
37 const dir = mkdtempSync(join(tmpdir(), "forge-kpi-"));
38 try {
39 const inner = `<!doctype html><meta name=viewport content="width=device-width"><style>
40 :root{--border:#ccc;--card:#fff;--fg:#000;--radius:6px;--muted-fg:#666;--fs-min:12px;--attn:red}
41 body{margin:16px}${components}</style>${tiles}`;
42 const page = `<!doctype html><body><iframe id=f width=${width} height=900 style="border:0" srcdoc="${inner
43 .replace(/&/g, "&amp;").replace(/"/g, "&quot;")}"></iframe><pre id=out></pre><script>
44 document.getElementById("f").onload=function(){var d=this.contentDocument;
45 var xs=new Set([].map.call(d.querySelectorAll(".kpi-row > .stat"),function(e){return Math.round(e.getBoundingClientRect().left)}));
46 document.getElementById("out").textContent="COLS="+xs.size+" N="+d.querySelectorAll(".kpi-row > .stat").length}</script>`;
47 const file = join(dir, "p.html");
48 writeFileSync(file, page);
49 const run = Bun.spawnSync(
50 [bin, "--headless", "--no-sandbox", "--disable-gpu", `--user-data-dir=${dir}/u`,
51 "--virtual-time-budget=3000", "--dump-dom", `file://${file}`],
52 { stdout: "pipe", stderr: "ignore", timeout: 20000 },
53 );
54 const m = /COLS=(\d+) N=(\d+)/.exec(run.stdout.toString());
55 if (m === null) throw new Error("no measurement: " + run.stdout.toString().slice(0, 300));
56 expect(Number(m[2])).toBeGreaterThan(2);
57 return Number(m[1]);
58 } finally {
59 rmSync(dir, { recursive: true, force: true });
60 }
61 }
62
63 test.skipIf(chromium === undefined)("at 360px the tiles sit two to a line; at 1000px more", () => {
64 expect(columnsAt(chromium!, 360)).toBe(2);
65 expect(columnsAt(chromium!, 1000)).toBeGreaterThan(2);
66 });
67
68 test("below 480px the row is two equal columns that may shrink to fit 328px", () => {
69 const m = /@media \(max-width: 480px\) \{ \.kpi-row \{ grid-template-columns: repeat\((\d+), minmax\(0, 1fr\)\); \} \}/.exec(components);
70 expect(m).not.toBeNull();
71 expect(Number(m![1])).toBe(2);
72 // minmax(0, …) has no floor, so nothing forces the 332px the 160px basis needed.
73 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs