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

The owner's /resilience page fits a 390 px phone: no sideways scroll, long names wrap #9362

Merged opened this

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

Merged

scout/forge:scout/9328-the-owner-s-resilience-page-fits-a-390-p → main 7 commits fork

  • Merged. scout/9328-the-owner-s-resilience-page-fits-a-390-p was merged into main as 4a21da9.

Review

Sign in to review this change.
4 files changed +180 −18
docs/design/areas/resilience.md added +11
@@ -0,0 +1,11 @@
1 # Resilience
2
3 `/resilience` (`src/web/pages/resilience.ts`) is a forge admin's: the backups line, the nightly jobs, a tally, and one
4 `row()` per protection in THREATS.md inside a `card` `.list`, its standing a `badge` word in the trail.
5
6 ## At 390 px (#9328)
7
8 A protection is a whole sentence and its cited test a path, so inside `[data-resilience]` (an attribute, not a class)
9 the row's title and meta wrap and long words break anywhere, in place of the list's one-line ellipsis; the row aligns
10 to its top so the badge sits by the first line. No fixed width; the page does not scroll sideways. No new class. The
11 owner's phone look of `/resilience` is taken on every preview.
src/spawn/preview-looks.ts +5
@@ -60,8 +60,12 @@ export const PREVIEW_ISSUES_PAGE = `${PREVIEW_REPO_HOME}/issues`;
6060 /** The pulls list, whose long titles must wrap at a 390 px phone (#9346). */
6161 export const PREVIEW_PULLS_PAGE = `${PREVIEW_REPO_HOME}/pulls`;
6262
63 /** The owner's resilience page, whose protections and test paths must wrap at a 390 px phone (#9328). */
64 export const PREVIEW_RESILIENCE_PAGE = "/resilience";
65
6366 /** The changelog, whose long landing titles must wrap at a 390 px phone (#9349). */
6467 export const PREVIEW_CHANGELOG_PAGE = "/changelog";
68
6569 /** The next-up page, whose long titles and action buttons must fit a 390 px phone (#9324). */
6670 export const PREVIEW_NEXT_PAGE = `${PREVIEW_REPO_HOME}/next`;
6771
@@ -108,6 +112,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [
108112 PREVIEW_NEXT_PAGE,
109113 PREVIEW_COMMITS_PAGE,
110114 PREVIEW_ISSUES_PAGE,
115 PREVIEW_RESILIENCE_PAGE,
111116 PREVIEW_SEARCH_PAGE,
112117 ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind })));
113118
src/web/pages/resilience.ts +35 −18
@@ -8,7 +8,7 @@
88 * any forge given no bucket — that they are off here and why, said plainly.
99 */
1010
11 import { readResilience } from "../../checks/resilience.ts";
11 import { readResilience, type Row } from "../../checks/resilience.ts";
1212 import type { Standing } from "../../core/threats.ts";
1313 import type { Ctx } from "../../http/context.ts";
1414 import { JOBS } from "../../nightly/jobs.ts";
@@ -61,6 +61,35 @@ export function nightlyOutputPage(ctx: Ctx): Response {
6161 return page({ ctx, title: `${job.name} · Resilience`, body });
6262 }
6363
64 /**
65 * A protection is a sentence from THREATS.md and its test a path, so inside
66 * `[data-resilience]` (an attribute, not a class) a row's title and meta wrap
67 * and a long word breaks anywhere, in place of the list's one-line ellipsis:
68 * the page never scrolls sideways at 390 px (#9328). No fixed width.
69 */
70 export const RESILIENCE_CSS = `[data-resilience] { overflow-wrap: anywhere; }
71 [data-resilience] .row-title, [data-resilience] .row-meta { white-space: normal; overflow: visible; text-overflow: clip; }
72 [data-resilience] .row { align-items: flex-start; }`;
73
74 /** One row per protection, its standing a word in the trail. */
75 export function protectionsHtml(rows: readonly Row[]): Html {
76 return card(
77 html`<ul class="list">
78 ${rows.map(
79 ({ p, s }) =>
80 html`<li class="row" data-standing="${s}">
81 <span class="row-main">
82 <span class="row-title">${p.text}</span>
83 <span class="row-meta">${p.adversary}${p.test === undefined ? "" : " · "}${p.test === undefined ? "" : html`<code>${p.test}</code>`}</span>
84 </span>
85 <span class="row-trail">${badge(s, { tone: TONE[s] })}</span>
86 </li>`,
87 )}
88 </ul>`,
89 { pad: false },
90 );
91 }
92
6493 // GET /resilience: a forge admin's, since live red lines name what to attack.
6594 export function resiliencePage(ctx: Ctx): Response {
6695 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -69,7 +98,8 @@ export function resiliencePage(ctx: Ctx): Response {
6998 const rows = timePhase(ctx.request, "threats", () => readResilience(ctx.app.config.dataDir));
7099 const rendering = performance.now();
71100 const count = (s: Standing): number => rows.filter((r) => r.s === s).length;
72 const body = html`<div class="page-head">
101 const body = html`<div data-resilience>
102 <div class="page-head">
73103 <h1>Resilience</h1>
74104 <p class="muted">
75105 Each protection in THREATS.md, judged by
@@ -82,22 +112,9 @@ export function resiliencePage(ctx: Ctx): Response {
82112 ${String(count("green"))} green · ${String(count("red"))} red ·
83113 ${String(count("untested"))} untested · ${String(count("no test yet"))} with no test yet
84114 </p>
85 ${card(
86 html`<ul class="list">
87 ${rows.map(
88 ({ p, s }) =>
89 html`<li class="row" data-standing="${s}">
90 <span class="row-main">
91 <span class="row-title">${p.text}</span>
92 <span class="row-meta">${p.adversary}${p.test === undefined ? "" : " · "}${p.test === undefined ? "" : html`<code>${p.test}</code>`}</span>
93 </span>
94 <span class="row-trail">${badge(s, { tone: TONE[s] })}</span>
95 </li>`,
96 )}
97 </ul>`,
98 { pad: false },
99 )}`;
100 const response = page({ ctx, title: "Resilience", body });
115 ${protectionsHtml(rows)}
116 </div>`;
117 const response = page({ ctx, title: "Resilience", body, styleCss: RESILIENCE_CSS });
101118 notePhase(ctx.request, "render", performance.now() - rendering);
102119 return response;
103120 }
test/resilience-fit.test.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * The owner's /resilience page fits a 390 px phone (#9328): the page as an
3 * admin sees it, plus the protection rows rendered with seeded long content
4 * (an unbroken 300-character word, a long sentence, many rows), ships no
5 * fixed width over 390 px, and the long-text container wraps anywhere.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import type { Row } from "../src/checks/resilience.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { findChromium } from "../src/looks/capture.ts";
17 import { PREVIEW_OWNER_LOOKS, PREVIEW_RESILIENCE_PAGE } from "../src/spawn/preview-looks.ts";
18 import { protectionsHtml, RESILIENCE_CSS } from "../src/web/pages/resilience.ts";
19 import { utilities as base } from "../src/web/style/base.ts";
20 import { components } from "../src/web/style/components.ts";
21 import { content } from "../src/web/style/content.ts";
22 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
23
24 const WORD = "R".repeat(300);
25 const SENTENCE = "A protection said at great length so that it cannot fit one line ".repeat(6).trim();
26 const ROWS: Row[] = Array.from({ length: 40 }, (_, i) => ({
27 p: {
28 adversary: i === 2 ? `Adversary ${"a".repeat(120)}` : `Adversary ${i}`,
29 text: i === 0 ? WORD : i === 1 ? SENTENCE : `Protection ${i}`,
30 test: i === 3 ? `test/${"t".repeat(150)}.test.ts` : i % 2 === 0 ? `test/p${i}.test.ts` : undefined,
31 },
32 s: (["green", "red", "untested", "no test yet"] as const)[i % 4]!,
33 }));
34
35 let dir: string;
36 let app: App;
37 let servers: Servers;
38 let page: string;
39 let adminId: string;
40 const rows = String(protectionsHtml(ROWS));
41
42 beforeAll(async () => {
43 dir = await mkdtemp(join(tmpdir(), "forge-resilience-fit-"));
44 app = createApp({ dataDir: dir, port: 0 });
45 servers = await serve(app);
46 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
47 if (!u.ok) throw new Error("user");
48 adminId = u.value.id;
49 const cookie = `forge_session=${createSession(app.db, u.value.id).token}`;
50 const res = await fetch(`${servers.url}/resilience`, { headers: { cookie } });
51 expect(res.status).toBe(200);
52 page = await res.text();
53 }, 30_000);
54 afterAll(async () => {
55 servers.stop();
56 app.close();
57 await rm(dir, { recursive: true, force: true });
58 });
59
60 const styles = (html: string): string[] => html.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [];
61
62 describe("the resilience page at 390 px", () => {
63 test("the seeded long content renders, in many rows, escaped", () => {
64 expect(rows).toContain(WORD);
65 expect(rows).toContain(SENTENCE);
66 expect(rows).toContain("t".repeat(150));
67 expect(rows.match(/class="row"/g)?.length).toBe(40);
68 });
69
70 test("the page draws its rows inside the wrapping container", () => {
71 expect(page).toContain("data-resilience");
72 expect(page.indexOf("data-resilience")).toBeLessThan(page.indexOf('class="list"'));
73 expect(page.match(/data-standing=/g)?.length ?? 0).toBeGreaterThan(5);
74 });
75
76 test("no inline width and no shipped rule fixes a width over 390 px", () => {
77 for (const html of [page, rows]) {
78 expect(html).not.toMatch(/style="[^"]*(?:^|[;\s])(?:min-|max-)?width\s*:/i);
79 for (const m of html.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
80 }
81 const over: string[] = [];
82 for (const css of [base, components, content, RESILIENCE_CSS, ...styles(page)]) {
83 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
84 if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]);
85 }
86 }
87 expect(over).toEqual([]);
88 });
89
90 test("the container wraps anywhere, and a row's title and meta may wrap", () => {
91 const css = styles(page).join("\n");
92 expect(css).toMatch(/\[data-resilience\]\s*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/);
93 expect(css).toMatch(/\[data-resilience\] \.row-title[^{]*\{[^}]*white-space:\s*normal/);
94 expect(css).toMatch(/\[data-resilience\] \.row-meta[^{]*\{[^}]*white-space:\s*normal/);
95 });
96 });
97
98 test("the owner's phone look takes the resilience page", () => {
99 expect(PREVIEW_RESILIENCE_PAGE).toBe("/resilience");
100 expect(PREVIEW_OWNER_LOOKS).toContainEqual({ page: PREVIEW_RESILIENCE_PAGE, kind: "phone" });
101 });
102
103 const found = findChromium();
104 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
105 if (chromium === undefined) console.log("SKIPPING: no chromium that starts here, so the resilience page's phone look is not measured");
106
107 describe.skipIf(chromium === undefined)("the resilience page, measured", () => {
108 const measure = `(() => {
109 const vw = document.documentElement.clientWidth;
110 const clipped = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { if (getComputedStyle(p).overflowX !== "visible") return true; } return false; };
111 const overflowing = [...document.querySelectorAll("[data-resilience], [data-resilience] *")].filter((el) => el.getBoundingClientRect().right > vw + 1 && !clipped(el)).slice(0, 8).map((el) => el.tagName.toLowerCase() + "." + String(el.className));
112 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, overflowing });
113 })()`;
114 const look = async (width: number, height: number) => {
115 const cookies = [{ name: "forge_session", value: createSession(app.db, adminId).token }];
116 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}${PREVIEW_RESILIENCE_PAGE}`, { width, height }, cookies, measure)) as { scrollWidth: number; viewportWidth: number; overflowing: string[] };
117 console.log(`resilience look ${width}x${height}: ${JSON.stringify(m)}`);
118 return m;
119 };
120 test("phone, 390x844: no sideways scroll", async () => {
121 const m = await look(390, 844);
122 expect(m.overflowing).toEqual([]);
123 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
124 }, 30_000);
125 test("desk, 1280x800: no sideways scroll", async () => {
126 const m = await look(1280, 800);
127 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
128 }, 30_000);
129 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs