Public: anyone can read this; the crew can change it.
Design: the held board (boards-held) is built from the design system, and its held-* classes leave §5d #3219
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3199-design-the-held-board-boards-held-is-bui → main 1 commit fork
4 files
changed +46
−58
docs/DESIGN.md +1 −1
| @@ -393,7 +393,7 @@ gone, so this list only shrinks. | ||
| 393 | 393 | - **`style/content.ts`, the editor** — the editor's parts beyond the §5 row: `.editor-mirror` `.editor-path` `.editor-preview` `.editor-stack` `.editor-tab` `.mirrored` `.ml` |
| 394 | 394 | - **`style/content.ts`, search results** — search results, the inbox's ask cards, the client list and the claim link: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.claim-link-box` `.forges-sent` `.live-toggle` `.needs-you` `.no-hits` |
| 395 | 395 | - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals` |
| 396 | - **`pages/flow.ts`** — /flow's pipeline bars, one `held-${kind}` per reason a stage holds work: `.flow-bar` `.flow-out` `.held-bar` `.held-done_on_main` `.held-key` `.held-open_pull` `.held-parked_twice` `.held-planner` `.held-red_fix` `.held-run_cap` `.held-running` `.held-setting` `.held-stalled` `.held-startable` | |
| 396 | - **`pages/flow-panel.ts`** — the pipeline's outflow sparklines, on /flow and /boards: `.flow-bar` `.flow-out` | |
| 397 | 397 | - **`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-lane` `.map-lane-caret` `.map-lane-crew` `.map-lane-head` `.map-lane-name` `.map-lane-word` `.map-lanes` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` |
| 398 | 398 | - **`pages/milestones.ts`** — milestones and one limb opened: `.ms-actions` `.ms-bar` `.ms-count` `.ms-figures` `.ms-head` `.ms-limb` `.ms-limbs` `.ms-more` `.ms-move` `.ms-name` `.ms-planner` `.ms-rows` `.ms-section` `.ms-state` `.ms-top` `.ms-trim` `.ms-weight` |
| 399 | 399 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` |
src/web/pages/boards-held.ts +37 −51
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | * Why ready issues wait, per autonomous repository: its ready list as one bar | |
| 3 | * with the count each filter holds (`flow/held.ts`). The flow panel | |
| 2 | * Why ready issues wait, per autonomous repository: its ready list as one | |
| 3 | * table row with the count each filter holds (`flow/held.ts`). The flow panel | |
| 4 | 4 | * (`flow-panel.ts`), on `/boards` and `/flow`, draws it from here. A private |
| 5 | 5 | * repository is in the rows for the owner only. |
| 6 | 6 | */ |
| @@ -8,48 +8,21 @@ | ||
| 8 | 8 | import { HOLDS } from "../../crew/held.ts"; |
| 9 | 9 | import type { HeldRow } from "../../flow/held.ts"; |
| 10 | 10 | import { html, type Html } from "../html.ts"; |
| 11 | import { card } from "../ui.ts"; | |
| 11 | import { card, meter } from "../ui.ts"; | |
| 12 | 12 | |
| 13 | export const heldCss = /* css */ ` | |
| 14 | .held-bar { width: 100%; height: 12px; display: block; } | |
| 15 | .held-key { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; font-variant-numeric: tabular-nums; } | |
| 16 | .held-key svg { width: 10px; height: 10px; } | |
| 17 | .held-startable { fill: var(--success); } | |
| 18 | .held-running { fill: var(--accent); } | |
| 19 | .held-open_pull { fill: var(--merged); } | |
| 20 | .held-planner { fill: var(--primary); } | |
| 21 | .held-done_on_main { fill: var(--add-fg); } | |
| 22 | .held-stalled { fill: var(--attn); } | |
| 23 | .held-run_cap { fill: var(--danger); } | |
| 24 | .held-parked_twice { fill: var(--del-fg); } | |
| 25 | .held-red_fix { fill: var(--muted-fg); } | |
| 26 | .held-setting { fill: var(--border-strong); } | |
| 27 | `; | |
| 28 | ||
| 29 | /** One segment per kind, as wide as its share: a picture only; the counts beside it are the words. */ | |
| 30 | function heldBar(row: HeldRow): Html { | |
| 31 | const parts: [string, number][] = [ | |
| 32 | ["startable", row.startable], | |
| 33 | ...HOLDS.map((h): [string, number] => [h, row.counts[h]]), | |
| 34 | ]; | |
| 35 | const total = parts.reduce((n, [, c]) => n + c, 0); | |
| 36 | let x = 0; | |
| 37 | const rects = parts | |
| 38 | .filter(([, c]) => c > 0) | |
| 39 | .map(([kind, c]) => { | |
| 40 | const w = (c / total) * 100; | |
| 41 | const rect = html`<rect class="held-${kind}" x="${x.toFixed(2)}" y="0" width="${w.toFixed(2)}" height="12" />`; | |
| 42 | x += w; | |
| 43 | return rect; | |
| 44 | }); | |
| 45 | return html`<svg class="held-bar" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden="true" focusable="false">${rects}</svg>`; | |
| 13 | function heldCount(kind: string, n: number): Html { | |
| 14 | return html`<span data-held="${kind}"><span class="mono">${kind}=${String(n)}</span></span>`; | |
| 46 | 15 | } |
| 47 | 16 | |
| 48 | function heldKey(kind: string, n: number): Html { | |
| 49 | return html`<span class="held-key small" data-held="${kind}"> | |
| 50 | <svg viewBox="0 0 10 10" aria-hidden="true" focusable="false"><rect class="held-${kind}" width="10" height="10" /></svg> | |
| 51 | <span class="mono">${kind}=${String(n)}</span> | |
| 52 | </span>`; | |
| 17 | /** The startable share of the ready list; `attn` when none of it can start. */ | |
| 18 | function startableMeter(row: HeldRow): Html { | |
| 19 | const total = row.startable + HOLDS.reduce((n, h) => n + row.counts[h], 0); | |
| 20 | if (total === 0) return html`<span class="muted">—</span>`; | |
| 21 | return meter( | |
| 22 | (row.startable / total) * 100, | |
| 23 | `${row.startable} of ${total} ready can start`, | |
| 24 | row.startable === 0 ? "attn" : undefined, | |
| 25 | ); | |
| 53 | 26 | } |
| 54 | 27 | |
| 55 | 28 | /** Why ready issues cannot start, per autonomous repository (#2244). */ |
| @@ -65,16 +38,29 @@ export function heldCard(rows: readonly HeldRow[]): Html { | ||
| 65 | 38 | </p> |
| 66 | 39 | ${rows.length === 0 |
| 67 | 40 | ? html`<p class="muted">No repository runs on its own.</p>` |
| 68 | : rows.map( | |
| 69 | (r) => html`<div data-held-repo="${r.repo}"> | |
| 70 | <p><a href="/${r.repo}/map">${r.repo}</a></p> | |
| 71 | ${heldBar(r)} | |
| 72 | <p> | |
| 73 | ${heldKey("startable", r.startable)} | |
| 74 | ${HOLDS.filter((h) => r.counts[h] > 0).map((h) => heldKey(h, r.counts[h]))} | |
| 75 | </p> | |
| 76 | </div>`, | |
| 77 | )}`, | |
| 41 | : html`<div class="table-scroll"> | |
| 42 | <table class="table" aria-label="Ready, and why it waits"> | |
| 43 | <thead> | |
| 44 | <tr> | |
| 45 | <th scope="col">Repository</th> | |
| 46 | <th scope="col">Can start</th> | |
| 47 | <th scope="col">Held by</th> | |
| 48 | </tr> | |
| 49 | </thead> | |
| 50 | <tbody> | |
| 51 | ${rows.map( | |
| 52 | (r) => | |
| 53 | html`<tr data-held-repo="${r.repo}"> | |
| 54 | <th scope="row"><a href="/${r.repo}/map">${r.repo}</a></th> | |
| 55 | <td>${heldCount("startable", r.startable)} ${startableMeter(r)}</td> | |
| 56 | <td> | |
| 57 | ${HOLDS.filter((h) => r.counts[h] > 0).map((h) => html`${heldCount(h, r.counts[h])} `)} | |
| 58 | </td> | |
| 59 | </tr>`, | |
| 60 | )} | |
| 61 | </tbody> | |
| 62 | </table> | |
| 63 | </div>`}`, | |
| 78 | 64 | { id: "held" }, |
| 79 | 65 | ); |
| 80 | 66 | } |
src/web/pages/flow-panel.ts +2 −2
| @@ -34,10 +34,10 @@ import type { QueueRow } from "../../flow/queue.ts"; | ||
| 34 | 34 | import { readHeld } from "../../flow/held.ts"; |
| 35 | 35 | import type { App } from "../../app.ts"; |
| 36 | 36 | import { html, type Html } from "../html.ts"; |
| 37 | import { heldCard, heldCss } from "./boards-held.ts"; | |
| 37 | import { heldCard } from "./boards-held.ts"; | |
| 38 | 38 | import { badge, card } from "../ui.ts"; |
| 39 | 39 | |
| 40 | export const flowPanelCss = heldCss + /* css */ ` | |
| 40 | export const flowPanelCss = /* css */ ` | |
| 41 | 41 | .flow-out { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; } |
| 42 | 42 | .flow-out svg { width: 72px; height: 16px; } |
| 43 | 43 | .flow-bar { fill: var(--muted-fg); } |
test/crew-held.test.ts +6 −4
| @@ -398,14 +398,16 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 398 | 398 | expect(res.status).toBe(200); |
| 399 | 399 | const page = await res.text(); |
| 400 | 400 | expect(page).toContain('id="held"'); |
| 401 | const row = page.slice(page.indexOf('data-held-repo="alice/open-door"')); | |
| 402 | expect(row).toContain('class="held-bar"'); | |
| 401 | const at = page.indexOf('data-held-repo="alice/open-door"'); | |
| 402 | const row = page.slice(at, page.indexOf("</tr>", at)); | |
| 403 | expect(row).toContain('<meter'); | |
| 404 | expect(row).toContain('aria-label="0 of 3 ready can start"'); | |
| 405 | expect(row).toContain('data-tone="attn"'); | |
| 403 | 406 | expect(row).toContain('<span class="mono">startable=0</span>'); |
| 404 | 407 | expect(row).toContain('<span class="mono">stalled=1</span>'); |
| 405 | 408 | expect(row).toContain('<span class="mono">done_on_main=1</span>'); |
| 406 | 409 | expect(row).toContain('data-held="done_on_main"'); |
| 407 | expect(row).toContain('<rect class="held-done_on_main"'); | |
| 408 | expect(page).toContain(".held-done_on_main { fill:"); | |
| 410 | expect(page).not.toContain("held-done_on_main"); | |
| 409 | 411 | expect(row).toContain('<span class="mono">run_cap=1</span>'); |
| 410 | 412 | expect(page).toContain('data-held-repo="alice/shut-door"'); |
| 411 | 413 |
new above
offline