Public: anyone can read this; the crew can change it.
A repository's runs list and a run's log page speak the home page's language #9739
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9727-a-repository-s-runs-list-and-a-run-s-log → main 4 commits fork 2 threads · 2 unresolved
7 files
changed +46
−4
docs/design/areas/runs.md +15
| @@ -14,3 +14,18 @@ own bounds. No fixed width; the page does not scroll sideways. No new class. | ||
| 14 | 14 | See it on a phone: `/preview-admin/sample/runs/<id>` renders a sample run's |
| 15 | 15 | page; at 390 px the head and live region wrap in place. That is asserted from |
| 16 | 16 | the HTML and CSS (`test/run-page-fit.test.ts`), not from a 390 px render. |
| 17 | ||
| 18 | ## Rows and space, not boxes (#9727) | |
| 19 | ||
| 20 | The runs list (`runsListPage`, `src/web/studio/lists.ts`) is a `.card.bare` list: one row per run, its state a | |
| 21 | badge, its agent and age in the row's meta, on the page's one background with no frame. A run's log (`.run-log`) | |
| 22 | has no box either: no fill, border or radius, just space around its rows, still wrapping and scrolling inside its | |
| 23 | own bounds; the steer form under it keeps one hairline above it. Filter, working tab, live tail, refresh, cancel and | |
| 24 | the `/runs/:id` door are unchanged. No new class. | |
| 25 | ||
| 26 | The app page's log tail (`.run-log.log-tail`, a peek at a running row's log) shares `.run-log`, so it loses its | |
| 27 | frame too. That is intended: a peek under a row reads as part of the row, not a box inside it. | |
| 28 | ||
| 29 | A check run's step list (`#check-steps`) is a `.card.bare` list too, so `/runs/:id` has no boxed card. At 390 px a | |
| 30 | log row's only fixed width is its 72px speaker column; the words (`.row-main`, `min-width: 0`) take the rest and | |
| 31 | wrap (`test/run-page-fit.test.ts`). |
src/web/studio/lists.ts +1
| @@ -870,6 +870,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 870 | 870 | trail: wordBadge(info.word), |
| 871 | 871 | }); |
| 872 | 872 | }), |
| 873 | { extra: "bare" }, | |
| 873 | 874 | ); |
| 874 | 875 | const last = fetched[fetched.length - 1]; |
| 875 | 876 |
src/web/studio/run.ts +1
| @@ -307,6 +307,7 @@ export async function runView( | ||
| 307 | 307 | }), |
| 308 | 308 | }), |
| 309 | 309 | ), |
| 310 | { extra: "bare" }, | |
| 310 | 311 | )} |
| 311 | 312 | ${known |
| 312 | 313 | ? html`<progress aria-label="Test files started" max="${row.of ?? 0}" value="${row.started ?? 0}"></progress>` |
src/web/style/content.ts +3 −4
| @@ -676,11 +676,10 @@ form.inline { display: inline; margin-left: auto; } | ||
| 676 | 676 | head stays reachable, and a composer that sits under it. */ |
| 677 | 677 | #run-live { position: relative; } |
| 678 | 678 | .run-log { |
| 679 | margin: 0; padding: 10px 14px 8px; max-height: calc(100dvh - 300px); min-height: 12em; overflow: auto; | |
| 680 | background: var(--muted); border: 1px solid var(--border); border-radius: var(--radius); | |
| 679 | margin: 0; padding: 8px 0; max-height: calc(100dvh - 300px); min-height: 12em; overflow: auto; | |
| 680 | background: none; border: 0; border-radius: 0; | |
| 681 | 681 | font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; tab-size: 4; |
| 682 | 682 | } |
Principle 6/7: the log is unframed, but the check step list on the same page (run.ts:299 |
||
| 683 | #run-live:has(#run-steer) .run-log { border-radius: var(--radius) var(--radius) 0 0; } | |
| 684 | 683 | .run-log .row { padding: 2px 0; min-height: 0; align-items: flex-start; } |
| 685 | 684 | .run-log .row-lead { flex: 0 0 72px; justify-content: flex-start; overflow: hidden; } |
| 686 | 685 | .run-log .row-lead .badge { overflow: hidden; text-overflow: ellipsis; } |
| @@ -697,7 +696,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 697 | 696 | } |
| 698 | 697 | #run-steer { |
| 699 | 698 | position: sticky; bottom: 0; gap: 6px; padding: 10px 12px; background: var(--bg); |
| 700 | border-top: 0; border-radius: 0 0 var(--radius) var(--radius); box-shadow: none; | |
| 699 | border: 0; border-top: 1px solid var(--border); border-radius: 0; box-shadow: none; | |
| 701 | 700 | } |
| 702 | 701 | #run-steer textarea { min-height: 38px; max-height: 160px; resize: none; } |
| 703 | 702 | #run-steer .bar .muted { flex: 1 1 200px; } |
test/run-page-fit.test.ts +16
| @@ -95,6 +95,22 @@ describe("a crew run's page at 390 px", () => { | ||
| 95 | 95 | const rule = content.match(/\.run-log \{[^}]*\}/)?.[0] ?? ""; |
| 96 | 96 | expect(rule).toMatch(/overflow-wrap:\s*anywhere/); |
| 97 | 97 | expect(rule).toMatch(/overflow:\s*auto/); |
| 98 | // Space, not a frame (#9727). | |
| 99 | expect(rule).toMatch(/border:\s*0/); | |
| 100 | expect(rule).toMatch(/background:\s*none/); | |
| 98 | 101 | expect(content).toMatch(/\.run-log \.row-title \{[^}]*white-space:\s*pre-wrap/); |
| 99 | 102 | }); |
| 103 | ||
| 104 | test("with the frame gone, a log row fixes no width beyond its 72px speaker column (#9727)", () => { | |
| 105 | const rule = (sel: string) => | |
| 106 | [...content.matchAll(/([^{}]+)\{([^}]*)\}/g)].find((m) => m[1]!.trim() === sel)?.[2] ?? ""; | |
| 107 | // The log has no side padding and no border: its rows get the page's whole width. | |
| 108 | expect(rule(".run-log")).toMatch(/padding:\s*8px 0;/); | |
| 109 | // The speaker column is the only fixed width: 72px, well under 390. | |
| 110 | expect(rule(".run-log .row-lead")).toMatch(/flex:\s*0 0 72px/); | |
| 111 | // The words take what is left and may shrink below their content, wrapping in place. | |
| 112 | expect(components).toMatch(/\.row-main \{[^}]*flex:\s*1;[^}]*min-width:\s*0/); | |
| 113 | expect(rule(".run-log .row-title")).toMatch(/white-space:\s*pre-wrap/); | |
| 114 | expect(rule(".run-log .row-title")).not.toMatch(/nowrap|min-width/); | |
| 115 | }); | |
| 100 | 116 | }); |
test/runs-state-filter.test.ts +6
| @@ -76,3 +76,9 @@ test("a private repository is still the one 404 to an outsider", async () => { | ||
| 76 | 76 | expect(a.status).toBe(404); |
| 77 | 77 | expect(b.status).toBe(404); |
| 78 | 78 | }); |
| 79 | ||
| 80 | test("the runs list is bare rows, not a boxed card (#9727)", async () => { | |
| 81 | const { body } = await get("/alice/notes/runs?state=failed"); | |
| 82 | expect(body).toMatch(/<div\s+class="card bare"[^>]*>\s*<ul class="list">/); | |
| 83 | expect(body).toContain(`/alice/notes/runs/${failedId}`); | |
| 84 | }); |
test/studio-runs.test.ts +4
| @@ -891,6 +891,10 @@ describe("working: a run's own page in the studio", () => { | ||
| 891 | 891 | expect(live).toBeGreaterThan(-1); |
| 892 | 892 | expect(page.indexOf('id="check-pins"')).toBeGreaterThan(page.indexOf("Test files")); |
| 893 | 893 | expect(page.slice(live, page.indexOf('id="check-pins"'))).not.toContain("a breaks"); |
| 894 | // The steps are bare rows, not a boxed card (#9727). | |
| 895 | expect(page.slice(page.indexOf('id="check-steps"'), page.indexOf('id="check-pins"'))).toMatch( | |
| 896 | /<div\s+class="card bare"[^>]*>\s*<ul class="list">/, | |
| 897 | ); | |
| 894 | 898 | // The replaced element (#check-steps) closes before the pin slot, which is |
| 895 | 899 | // patched in place so focus and scroll survive a refetch. |
| 896 | 900 | const at = page.indexOf('id="check-steps"'); |
Principle 7 (rows over boxes) is met here, but no test would fail without { extra: "bare" }. Add a test in test/runs-*.test.ts asserting the runs list renders card bare.