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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6067-the-home-s-kpi-row-wraps-to-a-second-lin → main 5 commits fork 2 threads · 2 unresolved
7 files
changed +83
−8
docs/design/areas/home.md +1
| @@ -2,3 +2,4 @@ | ||
| 2 | 2 | |
| 3 | 3 | - `.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. |
| 4 | 4 | - `.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 | ||
| 95 | 95 | const overdue = backupOverdue(k.backupAt, now); |
| 96 | 96 | const spendWords = spendCeilingWords(k.spend); |
| 97 | 97 | 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"> | |
| 99 | 99 | ${tile( |
| 100 | 100 | "Shipped per hour", |
| 101 | 101 | "/board", |
src/web/style/components.ts +1
| @@ -156,6 +156,7 @@ fieldset.field > .check + .check { margin-top: 8px; } | ||
| 156 | 156 | overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); |
| 157 | 157 | background: var(--card); color: var(--fg); text-decoration: none; |
| 158 | 158 | } |
| 159 | @media (max-width: 480px) { .kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } } | |
| 159 | 160 | a.stat:hover { border-color: var(--border-strong); } |
| 160 | 161 | .stat[data-tone="attn"] { border-color: color-mix(in srgb, var(--attn) 40%, var(--border)); } |
| 161 | 162 | .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", () => { | ||
| 405 | 405 | }); |
| 406 | 406 | |
| 407 | 407 | 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"'); | |
| 410 | 410 | }); |
| 411 | 411 | |
| 412 | 412 | 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 () => { | ||
| 45 | 45 | |
| 46 | 46 | test("a warm home reads neither the KPIs nor the panels again, even after a write", async () => { |
| 47 | 47 | const a = await home(f.ownerCookie); |
| 48 | expect(a).toContain('class="stats mb"'); | |
| 48 | expect(a).toContain('class="stats kpi-row mb"'); | |
| 49 | 49 | expect(a).toContain("<h2>Pin A</h2>"); |
| 50 | 50 | const kpi = kpiCache(f.app.db); |
| 51 | 51 | 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", | ||
| 66 | 66 | await home(f.ownerCookie); |
| 67 | 67 | for (const cookie of [f.peerCookie, undefined]) { |
| 68 | 68 | const page = await home(cookie); |
| 69 | expect(page).not.toContain('class="stats mb"'); | |
| 69 | expect(page).not.toContain('class="stats kpi-row mb"'); | |
| 70 | 70 | expect(page).not.toContain('class="stat"'); |
| 71 | 71 | expect(page).not.toContain("Pin A"); |
| 72 | 72 | } |
test/home-kpi-page.test.ts +3 −3
| @@ -59,7 +59,7 @@ test("home renders the nine tiles; only an admin sees them", async () => { | ||
| 59 | 59 | view(day, "a", 1900, null); |
| 60 | 60 | view(yesterday, "z", 400, 160); |
| 61 | 61 | const a = await home(admin); |
| 62 | expect(a).toContain('class="stats mb"'); | |
| 62 | expect(a).toContain('class="stats kpi-row mb"'); | |
| 63 | 63 | expect((a.match(/class="stat"/g) ?? []).length).toBe(9); |
| 64 | 64 | for (const href of ["/board", "/releases", "/boards", "/settings/crew", "/settings/backups"]) expect(a).toContain(`class="stat" href="${href}"`); |
| 65 | 65 | // 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 () => { | ||
| 78 | 78 | // The figures count every repository, so a non-admin sees none. |
| 79 | 79 | expect(a).toContain("no forge ceiling"); |
| 80 | 80 | 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"'); | |
| 82 | 82 | 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"'); | |
| 84 | 84 | expect(await home()).not.toContain("last 30 days"); |
| 85 | 85 | }); |
| 86 | 86 |
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"; | |
| 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, "&").replace(/"/g, """)}"></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 | }); |
new above
offline
These regexes check CSS text, not behaviour, and would pass with the rule overridden. Assert the column count or tile width at 360px.