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

The check summary's vitals table names a phone look that scrolls sideways and how many screens tall each look is #9389

Merged opened this

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

Merged

scout/forge:scout/9320-the-check-summary-s-vitals-table-names-a → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/9320-the-check-summary-s-vitals-table-names-a was merged into main as 854bd48.

Review

Sign in to review this change.
9 files changed +118 −20
docs/features/vitals-screens.md added +8
@@ -0,0 +1,8 @@
1 # Screens and sideways scroll in the check's vitals (#9320)
2
3 A check's per-look vitals say how many screens tall each look is (`scrollHeight / innerHeight`, one decimal; empty when the look recorded no size) and name any phone look that scrolls sideways (`scrollWidth > innerWidth`).
4
5 - Run page: a right-aligned **Screens** column in the vitals table and a muted line under it, `phone /x scrolls sideways`.
6 - Terminal: the checks line carries the same, `vitals home: LCP …, 2.4 screens; phone: …, 1.1 screens · phone /x scrolls sideways` (`vitalsLine`, `src/checks/progress.ts`).
7
8 Report-only: neither can turn a check red. Skipped looks show no screens.
src/checks/looks.ts +16 −3
@@ -10,7 +10,7 @@
1010
1111 import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1212 import { join } from "node:path";
13 import type { LookVitals } from "./summary.ts";
13 import type { LookVitals, PageSize } from "./summary.ts";
1414 import { runsRoot } from "../runs/log.ts";
1515
1616 export const looksPath = (dataDir: string, runId: string): string =>
@@ -36,6 +36,17 @@ const MAX_WORDS = 80;
3636 const words = (v: unknown): string | null =>
3737 typeof v === "string" && v.trim() !== "" ? v.trim().slice(0, MAX_WORDS) : null;
3838
39 const whole = (v: unknown): v is number =>
40 typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1e6;
41
42 function parseSize(v: unknown): PageSize | undefined {
43 if (!v || typeof v !== "object") return undefined;
44 const { scrollWidth, scrollHeight, innerWidth, innerHeight } = v as Record<string, unknown>;
45 return whole(scrollWidth) && whole(scrollHeight) && whole(innerWidth) && whole(innerHeight)
46 ? { scrollWidth, scrollHeight, innerWidth, innerHeight }
47 : undefined;
48 }
49
3950 /** Plain data in: the parsed file's value, narrowed to the looks it validly holds. */
4051 export function parseLooks(raw: unknown): LookVitals[] {
4152 if (!Array.isArray(raw)) return [];
@@ -47,8 +58,10 @@ export function parseLooks(raw: unknown): LookVitals[] {
4758 if (look === null) continue;
4859 const skipped = words(rec.skipped);
4960 if (skipped !== null) out.push({ look, skipped });
50 else if (rec.entries && typeof rec.entries === "object" && !Array.isArray(rec.entries))
51 out.push({ look, entries: rec.entries });
61 else if (rec.entries && typeof rec.entries === "object" && !Array.isArray(rec.entries)) {
62 const size = parseSize(rec.size);
63 out.push({ look, entries: rec.entries, ...(size === undefined ? {} : { size }) });
64 }
5265 }
5366 return out;
5467 }
src/checks/progress.ts +10 −7
@@ -9,7 +9,7 @@
99
1010 import type { CheckSummary } from "../core/runs.ts";
1111 import { readLooks } from "./looks.ts";
12 import { checkSteps, stepsWords, vitalsRows, type LookVitals } from "./summary.ts";
12 import { checkSteps, sidewaysLine, stepsWords, vitalsRows, type LookVitals } from "./summary.ts";
1313 import { parseTestsRow, testsRowLine } from "./tests-row.ts";
1414 import { readRunLog } from "../runs/log.ts";
1515
@@ -115,10 +115,13 @@ export function stepsLine(
115115
116116 /** `vitals home: LCP 3100 ms needs-improvement, CLS 0.3 poor, TBT 100 ms good; mobile: skipped: no browser` */
117117 export function vitalsLine(looks: readonly LookVitals[]): string {
118 const groups = vitalsRows(looks).map((g) =>
119 g.skipped !== ""
120 ? `${g.look}: skipped: ${g.skipped}`
121 : `${g.look}: ${g.rows.map((r) => `${r.vital} ${r.value}${r.grade === "n/a" ? "" : ` ${r.grade}`}`).join(", ")}`,
122 );
123 return groups.length === 0 ? "" : `vitals ${groups.join("; ")}`;
118 const rows = vitalsRows(looks);
119 const groups = rows.map((g) => {
120 if (g.skipped !== "") return `${g.look}: skipped: ${g.skipped}`;
121 const vitals = g.rows.map((r) => `${r.vital} ${r.value}${r.grade === "n/a" ? "" : ` ${r.grade}`}`);
122 return `${g.look}: ${vitals.join(", ")}${g.screens === "" ? "" : `, ${g.screens} screens`}`;
123 });
124 if (groups.length === 0) return "";
125 const sideways = sidewaysLine(rows);
126 return `vitals ${groups.join("; ")}${sideways === "" ? "" : ` · ${sideways}`}`;
124127 }
src/checks/summary.ts +23 −3
@@ -13,6 +13,8 @@ import { fullShard, OUT_OF_ROOM, TEMP_FULL } from "./disk_full.ts";
1313 import { oneLine } from "./words.ts";
1414 import { clip } from "../text/clip.ts";
1515 import { webvitals, type Entries } from "../release/webvitals.ts";
16 import type { PageSize } from "../looks/capture.ts";
17 export type { PageSize };
1618
1719 /**
1820 * The first line of the block a timed full gate ends its log with
@@ -220,7 +222,7 @@ export function stepsWords(steps: readonly CheckStep[]): string {
220222
221223 /** One look's recorded entries, or why it has none; plain data from the caller. */
222224 export type LookVitals =
223 | { readonly look: string; readonly entries: Entries }
225 | { readonly look: string; readonly entries: Entries; readonly size?: PageSize }
224226 | { readonly look: string; readonly skipped: string };
225227
226228 /** One cell row of a look's table: words only, escaped where they are rendered. */
@@ -237,6 +239,20 @@ export interface LookRows {
237239 /** Why the look has no numbers (`no browser`); empty when it has rows. */
238240 readonly skipped: string;
239241 readonly rows: readonly VitalsRow[];
242 /** Screens tall (scrollHeight / innerHeight, one decimal); empty without a size. */
243 readonly screens: string;
244 /** A phone look whose page is wider than its window; false without a size. */
245 readonly sideways: boolean;
246 }
247
248 const isPhone = (look: string): boolean => look === "phone" || look.startsWith("phone ");
249
250 /** `phone /x scrolls sideways` for each phone look wider than its window; empty when none. */
251 export function sidewaysLine(groups: readonly LookRows[]): string {
252 return groups
253 .filter((g) => g.sideways)
254 .map((g) => `${g.look} scrolls sideways`)
255 .join("; ");
240256 }
241257
242258 const VITAL_ROWS = [
@@ -253,7 +269,7 @@ const shown = (n: number, unit: string): string => `${n}${unit === "" ? "" : ` $
253269 */
254270 export function vitalsRows(looks: readonly LookVitals[]): readonly LookRows[] {
255271 return looks.map((l) => {
256 if ("skipped" in l) return { look: l.look, skipped: l.skipped, rows: [] };
272 if ("skipped" in l) return { look: l.look, skipped: l.skipped, rows: [], screens: "", sideways: false };
257273 const v = webvitals(l.entries);
258274 const rows = VITAL_ROWS.map(([vital, key]): VitalsRow => {
259275 const x = v[key];
@@ -264,6 +280,10 @@ export function vitalsRows(looks: readonly LookVitals[]): readonly LookRows[] {
264280 grade: x.grade ?? "n/a",
265281 };
266282 });
267 return { look: l.look, skipped: "", rows };
283 const z = l.size;
284 const tall = z === undefined || z.innerHeight <= 0 ? null : z.scrollHeight / z.innerHeight;
outdated

Over-long line; split it. Terminal vitalsLine in progress.ts should carry screens and the sideways note too.

285 const screens = tall === null ? "" : (Math.round(tall * 10) / 10).toFixed(1);
286 const sideways = z !== undefined && isPhone(l.look) && z.scrollWidth > z.innerWidth;
287 return { look: l.look, skipped: "", rows, screens, sideways };
268288 });
269289 }
src/looks/service.ts +4 −1
@@ -432,7 +432,10 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
432432 // The thumb is the desktop page again, as a stranger: it adds no row.
433433 if (kind !== "thumb" && theme === "light") {
434434 const look = page === "/" ? kind : `${kind} ${page}`;
435 if (shot.ok) vitals.push({ look, entries: shot.value.entries });
435 if (shot.ok) {
436 const size = shot.value.size === undefined ? {} : { size: shot.value.size };
437 vitals.push({ look, entries: shot.value.entries, ...size });
438 }
436439 else if (isNoBrowser(shot.error))
437440 vitals.push({ look, skipped: "no browser" });
438441 }
src/web/studio/vitals.ts +6 −5
@@ -4,24 +4,25 @@
44 * look recorded anything.
55 */
66
7 import { vitalsRows, type LookVitals } from "../../checks/summary.ts";
7 import { sidewaysLine, vitalsRows, type LookVitals } from "../../checks/summary.ts";
88 import { html, type Html } from "../html.ts";
99
1010 export function vitalsTable(looks: readonly LookVitals[]): Html {
1111 const groups = vitalsRows(looks);
1212 if (groups.length === 0) return html``;
13 const sideways = sidewaysLine(groups);
1314 return html`<div class="table-scroll" tabindex="0" role="region" aria-label="Web vitals per look">
1415 <table class="table" data-dense>
15 <thead><tr><th>Look</th><th>Vital</th><th>Value</th><th>Thresholds</th><th>Grade</th></tr></thead>
16 <thead><tr><th>Look</th><th>Vital</th><th>Value</th><th>Thresholds</th><th>Grade</th><th class="num">Screens</th></tr></thead>
1617 <tbody>
1718 ${groups.map((g) =>
1819 g.skipped !== ""
19 ? html`<tr><th scope="row">${g.look}</th><td colspan="4">skipped: ${g.skipped}</td></tr>`
20 ? html`<tr><th scope="row">${g.look}</th><td colspan="5">skipped: ${g.skipped}</td></tr>`
2021 : g.rows.map(
21 (r, i) => html`<tr>${i === 0 ? html`<th scope="row" rowspan="${g.rows.length}">${g.look}</th>` : ""}<td>${r.vital}</td><td>${r.value}</td><td>${r.thresholds}</td><td>${r.grade}</td></tr>`,
22 (r, i) => html`<tr>${i === 0 ? html`<th scope="row" rowspan="${g.rows.length}">${g.look}</th>` : ""}<td>${r.vital}</td><td>${r.value}</td><td>${r.thresholds}</td><td>${r.grade}</td>${i === 0 ? html`<td class="num" rowspan="${g.rows.length}">${g.screens}</td>` : ""}</tr>`,
2223 ),
2324 )}
2425 </tbody>

Bare <p>: use the named muted-text component from DESIGN.md. Also compute from groups instead of calling sidewaysLine, which re-runs vitalsRows.

2526 </table>
26 </div>`;
27 </div>${sideways === "" ? "" : html`<p class="muted small" data-sideways>${sideways}</p>`}`;
2728 }
test/check-vitals-size.test.ts added +45
@@ -0,0 +1,45 @@
1 /** A look's recorded size: kept by the parser, shown as screens, and named when a phone look scrolls sideways (#9320). */
2
3 import { expect, test } from "bun:test";
4 import { parseLooks } from "../src/checks/looks.ts";
5 import { sidewaysLine, vitalsRows } from "../src/checks/summary.ts";
6 import { vitalsLine } from "../src/checks/progress.ts";
7 import { vitalsTable } from "../src/web/studio/vitals.ts";
8
9 const size = (scrollWidth: number, scrollHeight: number) => ({ scrollWidth, scrollHeight, innerWidth: 390, innerHeight: 800 });
10
11 test("a valid size survives parsing; a broken one is dropped", () => {
12 const [a, b] = parseLooks([
13 { look: "phone", entries: {}, size: size(390, 1000) },
14 { look: "phone /x", entries: {}, size: { scrollWidth: "wide" } },
15 ]);
16 expect(a).toMatchObject({ size: size(390, 1000) });
17 expect(b).toEqual({ look: "phone /x", entries: {} });
18 });
19
20 test("screens is scrollHeight over innerHeight to one decimal", () => {
21 const g = vitalsRows([{ look: "phone", entries: {}, size: size(390, 2000) }, { look: "desktop", entries: {} }]);
22 expect(g[0]!.screens).toBe("2.5");
23 expect(g[1]!.screens).toBe("");
24 expect(vitalsTable([{ look: "phone", entries: {}, size: size(390, 1000) }]).toString()).toContain(">1.3<");
25 });
26
27 test("the sideways line names a wide phone look and only that", () => {
28 const wide = { look: "phone /preview-admin/sample/held", entries: {}, size: size(500, 800) };
29 expect(sidewaysLine(vitalsRows([wide]))).toBe("phone /preview-admin/sample/held scrolls sideways");
30 expect(vitalsTable([wide]).toString()).toContain("phone /preview-admin/sample/held scrolls sideways");
31 const fits = { look: "phone", entries: {}, size: size(390, 800) };
32 const desktop = { look: "desktop", entries: {}, size: size(2000, 800) };
33 const none = { look: "phone /y", entries: {} };
34 expect(sidewaysLine(vitalsRows([fits, desktop, none]))).toBe("");
35 expect(vitalsTable([fits, desktop, none]).toString()).not.toContain("sideways");
36 });
37
38 test("the terminal line carries screens and the sideways note", () => {
39 const wide = { look: "phone /x", entries: {}, size: size(500, 1600) };
40 const line = vitalsLine([wide, { look: "desktop", skipped: "no browser" }]);
41 expect(line).toContain("phone /x:");
42 expect(line).toContain("2.0 screens");
43 expect(line).toContain("desktop: skipped: no browser · phone /x scrolls sideways");
44 expect(vitalsLine([{ look: "phone", entries: {} }])).not.toContain("screens");
45 });
test/check-vitals.test.ts +1 −1
@@ -32,7 +32,7 @@ describe("vitalsRows", () => {
3232
3333 test("a look without a browser carries its reason and no rows", () => {
3434 expect(vitalsRows([{ look: "mobile", skipped: "no browser" }])).toEqual([
35 { look: "mobile", skipped: "no browser", rows: [] },
35 { look: "mobile", skipped: "no browser", rows: [], screens: "", sideways: false },
3636 ]);
3737 });
3838 });
test/looks.test.ts +5
@@ -92,6 +92,9 @@ describe("the look", () => {
9292 writeFileSync(input.out, input.width === 1280 ? DESKTOP_PNG : PNG);
9393 // What the page recorded: a slow paint for the phone, a shift for the desktop.
9494 return ok({
95 ...(input.width === 390
96 ? { size: { scrollWidth: 390, scrollHeight: 1600, innerWidth: 390, innerHeight: 800 } }
97 : {}),
9598 entries:
9699 input.width === 390
97100 ? { lcp: [{ startTime: 3100, size: 10 }] }
@@ -294,6 +297,8 @@ describe("the look", () => {
294297 expect(recorded).toHaveLength(2);
295298 expect(recorded.map((l) => l.look).sort()).toEqual(["desktop", "phone"]);
296299 const graded = vitalsRows(recorded);
300 // The phone's recorded size is stored beside its entries: two screens tall.
301 expect(graded.find((g) => g.look === "phone")!.screens).toBe("2.0");
297302 expect(graded.find((g) => g.look === "phone")!.rows[0]).toMatchObject({
298303 vital: "LCP",
299304 value: "3100 ms",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs