Public: anyone can read this; the crew can change it.
A run page's pinned failures draw §5's card and row, and .pinned-failures, .pinned-slot leave the markup #4312
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4264-a-run-page-s-pinned-failures-draw-5-s-ca → main 7 commits fork 5 threads · 5 unresolved
4 files
changed +41
−22
docs/DESIGN.md +1 −1
| @@ -398,7 +398,7 @@ gone, so this list only shrinks. | ||
| 398 | 398 | - **`style/content.ts`, code view** — the source view and the syntax-highlight token classes `highlight-html.ts` builds as `tok-${kind}`: `.blob-view` `.ln` `.tok-attribute` `.tok-comment` `.tok-constant` `.tok-decorator` `.tok-deleted` `.tok-emphasis` `.tok-function` `.tok-heading` `.tok-inserted` `.tok-keyword` `.tok-link` `.tok-meta` `.tok-number` `.tok-operator` `.tok-plain` `.tok-property` `.tok-punctuation` `.tok-regex` `.tok-string` `.tok-tag` `.tok-type` `.tok-variable` |
| 399 | 399 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 400 | 400 | - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-hidden` `.rc-resolve` `.rc-resolved` `.wd` |
| 401 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**): `.ln-forge` `.ln-pulse` `.ln-say` `.ln-tool` `.ln-you` `.pinned-failures` `.pinned-slot` `.txt` `.who` | |
| 401 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**): `.ln-forge` `.ln-pulse` `.ln-say` `.ln-tool` `.ln-you` `.txt` `.who` | |
| 402 | 402 | - **`style/content.ts`, search results** — search results, the inbox's ask cards: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.live-toggle` |
| 403 | 403 | - **`pages/board.ts`** — the board itself is §5's **board**; the spend tables `spend.ts` draws wear §5's `.table` and `.stack`, no class of their own |
| 404 | 404 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` |
src/web/studio/run.ts +10 −12
| @@ -30,7 +30,7 @@ import { liveRegionsJs, studioRunJs } from "../client.ts"; | ||
| 30 | 30 | import { html, raw, when, type Html } from "../html.ts"; |
| 31 | 31 | import { runLine } from "../describe.ts"; |
| 32 | 32 | import { page } from "../layout.ts"; |
| 33 | import { badge, btn, listCard, row as stepRow, sha, type Tone } from "../ui.ts"; | |
| 33 | import { badge, btn, card, listCard, row as stepRow, sha, type Tone } from "../ui.ts"; | |
| 34 | 34 | import { previewCard } from "../pages/apps.ts"; |
| 35 | 35 | import { costText, durationText, runTitle, runTokens } from "../pages/runs.ts"; |
| 36 | 36 | import { placementLine, sandboxLine } from "./sandbox.ts"; |
| @@ -315,17 +315,15 @@ export async function runView( | ||
| 315 | 315 | : ""} |
| 316 | 316 | <p class="check-words small muted break">${[words, runningWords].filter((w) => w !== "").join(" · ")}</p> |
| 317 | 317 | </div> |
| 318 | <div class="pinned-slot" id="check-pins"${row.failures.length > 0 ? raw(' role="region" tabindex="0" aria-label="Failed tests"') : ""}> | |
| 319 | ${when( | |
| 320 | row.failures.length > 0, | |
| 321 | () => html`<ul class="pinned-failures"> | |
| 322 | ${row.failures.map( | |
| 323 | (f) => html`<li><strong>${f.name}</strong>${when( | |
| 324 | f.lines.length > 0, | |
| 325 | () => html`<pre>${f.lines.join("\n")}</pre>`, | |
| 326 | )}</li>`, | |
| 327 | )} | |
| 328 | </ul>`, | |
| 318 | <div data-pins id="check-pins"${row.failures.length > 0 ? raw(' role="region" tabindex="0" aria-label="Failed tests"') : ""}> | |
| 319 | ${row.failures.map((f) => | |
| 320 | card( | |
| 321 | html`<strong class="break">${f.name}</strong>${when( | |
| 322 | f.lines.length > 0, | |
| 323 | () => html`<pre class="break">${f.lines.join("\n")}</pre>`, | |
| 324 | )}`, | |
| 325 | { tone: "danger" }, | |
| 326 | ), | |
| 329 | 327 | )} |
| 330 | 328 | </div> |
| 331 | 329 | </div>`; |
src/web/style/content.ts +5 −5
| @@ -712,11 +712,11 @@ form.inline { display: inline; margin-left: auto; } | ||
| 712 | 712 | .checks-line .now { display: inline-flex; align-items: center; gap: 4px; color: var(--attn); } |
| 713 | 713 | .check-words { margin: 4px 0 0; min-height: 1.4em; } |
| 714 | 714 | /* Reserved only while the run is open: pins arriving never move the log. */ |
| 715 | .pinned-slot { max-height: 9em; overflow-y: auto; } | |
| 716 | [data-check-progress][data-state="queued"] .pinned-slot, [data-check-progress][data-state="running"] .pinned-slot { height: 9em; } | |
| 717 | .pinned-failures { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } | |
| 718 | .pinned-failures li { padding: 6px 12px; border: 1px solid var(--danger); border-radius: var(--radius); } | |
| 719 | .pinned-failures pre { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-min); } | |
| 715 | /* A plain block, not .stack: a flex column would shrink the clipped cards to fit 9em. */ | |
| 716 | [data-pins] { max-height: 9em; overflow-y: auto; } | |
| 717 | [data-check-progress][data-state="queued"] [data-pins], [data-check-progress][data-state="running"] [data-pins] { height: 9em; } | |
| 718 | [data-pins] .card + .card { margin-top: 12px; } | |
Local spacing rule on a card sibling; later move into a shared §5 stack variant that does not shrink children. |
||
| 719 | [data-pins] pre { margin: 4px 0 0; white-space: pre-wrap; font-size: var(--fs-min); } | |
| 720 | 720 | /* A run, live: a transcript with a speaker column, bounded so the page |
| 721 | 721 | head stays reachable, and a composer that sits under it. */ |
| 722 | 722 | #run-live { position: relative; } |
test/studio-runs.test.ts +25 −4
| @@ -879,13 +879,13 @@ describe("working: a run's own page in the studio", () => { | ||
| 879 | 879 | const page = await (await get(`/alice/engine/runs/${run.id}`, writerCookie)).text(); |
| 880 | 880 | expect(page).toContain('<progress aria-label="Test files started" max="9" value="2"'); |
| 881 | 881 | expect(page).toContain("2 of 9 files started · 4 passed, 1 failed · running test/b.test.ts"); |
| 882 | expect(page).toContain("<strong>a breaks</strong>"); | |
| 882 | expect(page).toContain('<strong class="break">a breaks</strong>'); | |
| 883 | 883 | expect(page).toContain("error: <b>boom</b>"); |
| 884 | 884 | // The pins sit after the live region closes. |
| 885 | 885 | const live = page.indexOf('role="status"'); |
| 886 | 886 | expect(live).toBeGreaterThan(-1); |
| 887 | expect(page.indexOf("pinned-failures")).toBeGreaterThan(page.indexOf("Test files")); | |
| 888 | expect(page.slice(live, page.indexOf("pinned-slot"))).not.toContain("a breaks"); | |
| 887 | expect(page.indexOf('id="check-pins"')).toBeGreaterThan(page.indexOf("Test files")); | |
| 888 | expect(page.slice(live, page.indexOf('id="check-pins"'))).not.toContain("a breaks"); | |
| 889 | 889 | // The replaced element (#check-steps) closes before the pin slot, which is |
| 890 | 890 | // patched in place so focus and scroll survive a refetch. |
| 891 | 891 | const at = page.indexOf('id="check-steps"'); |
| @@ -902,7 +902,11 @@ describe("working: a run's own page in the studio", () => { | ||
| 902 | 902 | expect(block).not.toContain("tabindex"); |
| 903 | 903 | expect(block).not.toContain('role="region"'); |
| 904 | 904 | // With pins, the slot is focusable and labelled; the live region holds no pin. |
| 905 | expect(page).toContain('class="pinned-slot" id="check-pins" role="region" tabindex="0" aria-label="Failed tests"'); | |
| 905 | expect(page).toContain('data-pins id="check-pins" role="region" tabindex="0" aria-label="Failed tests"'); | |
| 906 | expect(page).not.toMatch(/data-pins[^>]*class="[^"]*stack/); | |
| 907 | expect(page).not.toContain("pinned-failures"); | |
| 908 | expect(page).not.toContain("pinned-slot"); | |
| 909 | expect(page.slice(page.indexOf('id="check-pins"'))).toMatch(/class="card"\s+data-tone="danger"/); | |
| 906 | 910 | expect(page.slice(live, page.indexOf(`id="check-pins"`, live))).toContain("2 of 9 files started"); |
| 907 | 911 | expect(quiet).toContain('<p class="check-words small muted break"></p>'); // empty, but still rendered |
| 908 | 912 | for (const old of ["tests-bar", "tests-words"]) { |
| @@ -2333,6 +2337,23 @@ describe("a deploy that just landed is news on the front door", () => { | ||
| 2333 | 2337 | * decision ledger must ask before it computes or renders one of those. |
| 2334 | 2338 | */ |
| 2335 | 2339 | |
| 2340 | describe("the pin slot's stylesheet", () => { | |
| 2341 | test("caps [data-pins] with scroll and reserves it while a check is open", async () => { | |
| 2342 | const { css } = await import("../src/web/style.ts"); | |
| 2343 | expect(css).toContain("[data-pins] { max-height: 9em; overflow-y: auto; }"); | |
| 2344 | expect(css).toContain('[data-check-progress][data-state="running"] [data-pins] { height: 9em; }'); | |
| 2345 | expect(css).toContain('[data-check-progress][data-state="queued"] [data-pins]'); | |
| 2346 | expect(css).not.toContain(".pinned-"); | |
| 2347 | // Cards stack as blocks (a flex column would squash them into 9em), and output wraps. | |
| 2348 | expect(css).toContain("[data-pins] .card + .card { margin-top: 12px; }"); | |
| 2349 | expect(css).toContain("[data-pins] pre { margin: 4px 0 0; white-space: pre-wrap; font-size: var(--fs-min); }"); | |
| 2350 | }); | |
| 2351 | test("the slot is not a flex column", async () => { | |
| 2352 | const { css } = await import("../src/web/style.ts"); | |
| 2353 | expect(css).not.toMatch(/\[data-pins\][^{]*\{[^}]*display:\s*(flex|grid)/); | |
| 2354 | }); | |
| 2355 | }); | |
| 2356 | ||
| 2336 | 2357 | describe("run page controls leave the old classes behind (#4263)", () => { |
| 2337 | 2358 | test("no run-jump, run-live or run-steer class selector is defined, and [hidden] still beats .btn", async () => { |
| 2338 | 2359 | const { css } = await import("../src/web/style.ts"); |
new above
offline
data-pinsand.stackhave no height rule yet. The old.pinned-slotreserved 9em while the check ran and capped it with scroll afterwards. Add that on[data-pins]in this pull, and remove the dead.pinned-*rules.