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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9328-the-owner-s-resilience-page-fits-a-390-p → main 7 commits fork
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`; | ||
| 60 | 60 | /** The pulls list, whose long titles must wrap at a 390 px phone (#9346). */ |
| 61 | 61 | export const PREVIEW_PULLS_PAGE = `${PREVIEW_REPO_HOME}/pulls`; |
| 62 | 62 | |
| 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 | ||
| 63 | 66 | /** The changelog, whose long landing titles must wrap at a 390 px phone (#9349). */ |
| 64 | 67 | export const PREVIEW_CHANGELOG_PAGE = "/changelog"; |
| 68 | ||
| 65 | 69 | /** The next-up page, whose long titles and action buttons must fit a 390 px phone (#9324). */ |
| 66 | 70 | export const PREVIEW_NEXT_PAGE = `${PREVIEW_REPO_HOME}/next`; |
| 67 | 71 | |
| @@ -108,6 +112,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | ||
| 108 | 112 | PREVIEW_NEXT_PAGE, |
| 109 | 113 | PREVIEW_COMMITS_PAGE, |
| 110 | 114 | PREVIEW_ISSUES_PAGE, |
| 115 | PREVIEW_RESILIENCE_PAGE, | |
| 111 | 116 | PREVIEW_SEARCH_PAGE, |
| 112 | 117 | ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind }))); |
| 113 | 118 |
src/web/pages/resilience.ts +35 −18
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * any forge given no bucket — that they are off here and why, said plainly. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { readResilience } from "../../checks/resilience.ts"; | |
| 11 | import { readResilience, type Row } from "../../checks/resilience.ts"; | |
| 12 | 12 | import type { Standing } from "../../core/threats.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 14 | import { JOBS } from "../../nightly/jobs.ts"; |
| @@ -61,6 +61,35 @@ export function nightlyOutputPage(ctx: Ctx): Response { | ||
| 61 | 61 | return page({ ctx, title: `${job.name} · Resilience`, body }); |
| 62 | 62 | } |
| 63 | 63 | |
| 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 | ||
| 64 | 93 | // GET /resilience: a forge admin's, since live red lines name what to attack. |
| 65 | 94 | export function resiliencePage(ctx: Ctx): Response { |
| 66 | 95 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -69,7 +98,8 @@ export function resiliencePage(ctx: Ctx): Response { | ||
| 69 | 98 | const rows = timePhase(ctx.request, "threats", () => readResilience(ctx.app.config.dataDir)); |
| 70 | 99 | const rendering = performance.now(); |
| 71 | 100 | 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"> | |
| 73 | 103 | <h1>Resilience</h1> |
| 74 | 104 | <p class="muted"> |
| 75 | 105 | Each protection in THREATS.md, judged by |
| @@ -82,22 +112,9 @@ export function resiliencePage(ctx: Ctx): Response { | ||
| 82 | 112 | ${String(count("green"))} green · ${String(count("red"))} red · |
| 83 | 113 | ${String(count("untested"))} untested · ${String(count("no test yet"))} with no test yet |
| 84 | 114 | </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 }); | |
| 101 | 118 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 102 | 119 | return response; |
| 103 | 120 | } |
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 | }); |
new above
offline