Public: anyone can read this; the crew can change it.
Design: /milestones is built from the design system, and its ms-* classes leave §5d #3182
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3164-design-milestones-is-built-from-the-desi → main 7 commits fork 2 threads · 2 unresolved
7 files
changed +241
−257
docs/DESIGN.md +1 −1
| @@ -153,6 +153,7 @@ specification. | ||
| 153 | 153 | | **stat pill** `pill()` | `.pill` | `data-tone=accent\|danger` | tabular | a parenthesised count | |
| 154 | 154 | | **status dot** `dot()` | `.dot` | `data-tone=success\|danger\|attn\|merged` | — | `●` in the tone | |
| 155 | 155 | | **sha** `sha()` | `.sha` | — | first 7 chars | the same 7 chars, dim | |
| 156 | | **progress bar** `progress()` (`pages/milestones.ts`) | `progress` (element rule in `style/components.ts`; every `<progress>` on the site except `.tests-bar`, the run page's own bar) | — | native `<progress max value>`, 4px, full width, an `aria-label` saying what is done; no width rule per value | `▓▓▓░░ 3 of 5` | | |
| 156 | 157 | | **meter** `meter()` | `meter` | `data-tone=attn` when the share is a limit reached | native `<meter min=0 max=100>`, a whole percentage, an `aria-label` saying what it is a share of; no width rule per value, so no inline style | `▓▓▓░░ 62%` | |
| 157 | 158 | | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name | |
| 158 | 159 | | **card** `card()` / `listCard()` | `.card` (`.card-head`, `.card-body`, `.card-foot`) | `data-tone=success\|danger\|merged\|attn` | — | a box with a title bar | |
| @@ -395,7 +396,6 @@ gone, so this list only shrinks. | ||
| 395 | 396 | - **`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 | 397 | - **`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` |
| 397 | 398 | - **`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 | - **`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` |
| 400 | 400 | - **`studio/style.ts`** — the studio's tell box, app head, word and shelf rows: `.fcard-app` `.shelf-desc` `.shelf-facts` `.shelf-meta` `.studio-ask-actions` `.studio-head` `.studio-tell` `.studio-word` |
| 401 | 401 |
src/web/pages/limb.ts +30 −50
| @@ -32,16 +32,10 @@ import { | ||
| 32 | 32 | import { field, readForm } from "../form.ts"; |
| 33 | 33 | import { html, when, type Html } from "../html.ts"; |
| 34 | 34 | import { page, redirect } from "../layout.ts"; |
| 35 | import { | |
| 36 | badge, | |
| 37 | btn, | |
| 38 | field as formField, | |
| 39 | listCard, | |
| 40 | row, | |
| 41 | selectField, | |
| 42 | } from "../ui.ts"; | |
| 35 | import { badge, btn, card, field as formField, listCard, row, selectField } from "../ui.ts"; | |
| 43 | 36 | import { replanPreviewPage, replanSubmit } from "./limb_replan.ts"; |
| 44 | import { milestoneHref, milestonesCss } from "./milestones.ts"; | |
| 37 | import { folded, issueRow } from "./limb_view.ts"; | |
| 38 | import { milestoneHref } from "./milestones.ts"; | |
| 45 | 39 | |
| 46 | 40 | const WORDS: Record<LimbAction, { ask: string; does: string; go: string }> = { |
| 47 | 41 | defer: { |
| @@ -90,24 +84,11 @@ const shown = (value: FleetValue): string => | ||
| 90 | 84 | const SHOWN = 10; |
| 91 | 85 | |
| 92 | 86 | function issueList(base: string, issues: readonly LimbIssue[]): Html { |
| 93 | const row = (i: LimbIssue): Html => | |
| 94 | html`<li> | |
| 95 | <a href="${base}/issues/${i.number}">${i.title}</a> | |
| 96 | <span class="muted small">#${i.number}</span> | |
| 97 | </li>`; | |
| 98 | return html`<ul class="ms-rows"> | |
| 99 | ${issues.slice(0, SHOWN).map(row)} | |
| 100 | </ul> | |
| 101 | ${when( | |
| 102 | issues.length > SHOWN, | |
| 103 | () => | |
| 104 | html`<details class="ms-more"> | |
| 105 | <summary class="small">${issues.length - SHOWN} more</summary> | |
| 106 | <ul class="ms-rows"> | |
| 107 | ${issues.slice(SHOWN).map(row)} | |
| 108 | </ul> | |
| 109 | </details>`, | |
| 110 | )}`; | |
| 87 | return folded( | |
| 88 | issues.map((i) => issueRow(base, i)), | |
| 89 | "more", | |
| 90 | SHOWN, | |
| 91 | ); | |
| 111 | 92 | } |
| 112 | 93 | |
| 113 | 94 | /** |
| @@ -194,12 +175,11 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 194 | 175 | crumbs: crumbsFor(repo), |
| 195 | 176 | palette: repoPalette(ctx, repo), |
| 196 | 177 | body: html`${repoHeader(ctx, repo, "map")} |
| 197 | <div class="ms-top mt"> | |
| 178 | <div class="stack mt"> | |
| 198 | 179 | <h1>${words.ask} this limb?</h1> |
| 199 | 180 | <p id="limb-refused">Not as one action: ${plan.error.message}.</p> |
| 200 | 181 | <p><a href="${milestoneHref(base, number)}">Back</a></p> |
| 201 | 182 | </div>`, |
| 202 | styleCss: milestonesCss, | |
| 203 | 183 | }); |
| 204 | 184 | } |
| 205 | 185 | const p = plan.value; |
| @@ -208,7 +188,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 208 | 188 | const pillars = |
| 209 | 189 | action === "resume" ? pillarsOf(ctx.app.db, repo).pillars : []; |
| 210 | 190 | const body = html`${repoHeader(ctx, repo, "map")} |
| 211 | <div class="ms-top mt"> | |
| 191 | <div class="stack mt"> | |
| 212 | 192 | <p class="small muted"> |
| 213 | 193 | <a href="${milestoneHref(base, number)}">${p.limb.title}</a> |
| 214 | 194 | </p> |
| @@ -218,19 +198,21 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 218 | 198 | ${when( |
| 219 | 199 | p.order !== undefined, |
| 220 | 200 | () => |
| 221 | html`<section class="ms-section" id="limb-order"> | |
| 201 | html`<section class="mt" id="limb-order"> | |
| 222 | 202 | <h2>The order of the limbs</h2> |
| 223 | <ol class="ms-rows"> | |
| 224 | ${(p.order ?? []).map( | |
| 225 | (i) => | |
| 226 | html`<li ${i.number === p.limb.number ? html`aria-current="true"` : null}> | |
| 227 | ${i.number === p.limb.number | |
| 228 | ? html`<b>${i.title}</b>` | |
| 229 | : html`<a href="${base}/issues/${i.number}">${i.title}</a>`} | |
| 230 | <span class="muted small">#${i.number}</span> | |
| 231 | </li>`, | |
| 232 | )} | |
| 233 | </ol> | |
| 203 | ${card( | |
| 204 | html`<ol class="list"> | |
| 205 | ${(p.order ?? []).map((i) => | |
| 206 | i.number === p.limb.number | |
| 207 | ? row({ | |
| 208 | title: html`<b>${i.title}</b> | |
| 209 | <span class="muted small">#${i.number}</span>`, | |
| 210 | }) | |
| 211 | : issueRow(base, i), | |
| 212 | )} | |
| 213 | </ol>`, | |
| 214 | { pad: false }, | |
| 215 | )} | |
| 234 | 216 | </section>`, |
| 235 | 217 | )} |
| 236 | 218 | ${when( |
| @@ -238,7 +220,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 238 | 220 | () => { |
| 239 | 221 | const before = (p.ready?.before ?? []).map((i) => i.number); |
| 240 | 222 | const after = p.ready?.after ?? []; |
| 241 | return html`<section class="ms-section" id="limb-ready"> | |
| 223 | return html`<section class="mt" id="limb-ready"> | |
| 242 | 224 | <h2>The top of the ready list</h2> |
| 243 | 225 | ${after.length === 0 |
| 244 | 226 | ? html`<p class="muted small">Nothing is ready.</p>` |
| @@ -253,7 +235,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 253 | 235 | ${when( |
| 254 | 236 | p.taken.length > 0, |
| 255 | 237 | () => |
| 256 | html`<section class="ms-section" id="limb-taken"> | |
| 238 | html`<section class="mt" id="limb-taken"> | |
| 257 | 239 | <h2> |
| 258 | 240 | ${action === "resume" |
| 259 | 241 | ? `${p.taken.length} can be picked again` |
| @@ -265,7 +247,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 265 | 247 | ${when( |
| 266 | 248 | p.kept.length > 0, |
| 267 | 249 | () => |
| 268 | html`<section class="ms-section" id="limb-kept"> | |
| 250 | html`<section class="mt" id="limb-kept"> | |
| 269 | 251 | <h2> |
| 270 | 252 | ${p.kept.length} stay${p.kept.length === 1 ? "s" : ""}: something |
| 271 | 253 | outside this limb still waits for |
| @@ -276,7 +258,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 276 | 258 | )} |
| 277 | 259 | ${moving |
| 278 | 260 | ? null |
| 279 | : html`<section class="ms-section" id="limb-rows"> | |
| 261 | : html`<section class="mt" id="limb-rows"> | |
| 280 | 262 | <h2>What is written</h2> |
| 281 | 263 | ${p.rows.length === 0 |
| 282 | 264 | ? html`<p class="muted small">Nothing: no open issue is the limb's alone.</p>` |
| @@ -348,7 +330,6 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 348 | 330 | crumbs: crumbsFor(repo), |
| 349 | 331 | palette: repoPalette(ctx, repo), |
| 350 | 332 | body, |
| 351 | styleCss: milestonesCss, | |
| 352 | 333 | }); |
| 353 | 334 | } |
| 354 | 335 | |
| @@ -404,7 +385,7 @@ export function limbReceiptPage(ctx: Ctx): Response { | ||
| 404 | 385 | op.rows.filter((r) => r.outcome === outcome).length; |
| 405 | 386 | const others = op.rows.length - count("ok"); |
| 406 | 387 | const body = html`${repoHeader(ctx, repo, "map")} |
| 407 | <div class="ms-top mt"> | |
| 388 | <div class="stack mt"> | |
| 408 | 389 | <p class="small muted"> |
| 409 | 390 | <a href="${milestoneHref(base, number)}">Back to the milestone</a> |
| 410 | 391 | </p> |
| @@ -413,7 +394,7 @@ export function limbReceiptPage(ctx: Ctx): Response { | ||
| 413 | 394 | ${count("ok")} written${others === 0 ? "" : `, ${others} left as they were`}. |
| 414 | 395 | </p> |
| 415 | 396 | </div> |
| 416 | <section class="ms-section"> | |
| 397 | <section class="mt"> | |
| 417 | 398 | <h2>The receipt</h2> |
| 418 | 399 | ${rowList(ctx, base, op.rows)} |
| 419 | 400 | </section> |
| @@ -439,7 +420,6 @@ export function limbReceiptPage(ctx: Ctx): Response { | ||
| 439 | 420 | crumbs: crumbsFor(repo), |
| 440 | 421 | palette: repoPalette(ctx, repo), |
| 441 | 422 | body, |
| 442 | styleCss: milestonesCss, | |
| 443 | 423 | }); |
| 444 | 424 | } |
| 445 | 425 |
src/web/pages/limb_replan.ts +12 −16
| @@ -22,8 +22,9 @@ import { | ||
| 22 | 22 | import { html } from "../html.ts"; |
| 23 | 23 | import { page, redirect } from "../layout.ts"; |
| 24 | 24 | import { plural } from "../../kernel/plural.ts"; |
| 25 | import { btn } from "../ui.ts"; | |
| 26 | import { milestoneHref, milestonesCss } from "./milestones.ts"; | |
| 25 | import { btn, card } from "../ui.ts"; | |
| 26 | import { issueRow } from "./limb_view.ts"; | |
| 27 | import { milestoneHref } from "./milestones.ts"; | |
| 27 | 28 | |
| 28 | 29 | /** GET /:owner/:name/milestones/:number/limb?action=replan — what the planner will be given. */ |
| 29 | 30 | export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Response { |
| @@ -37,9 +38,8 @@ export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Respons | ||
| 37 | 38 | title: `Re-plan ${p.limb.title} · ${repo.name}`, |
| 38 | 39 | crumbs: crumbsFor(repo), |
| 39 | 40 | palette: repoPalette(ctx, repo), |
| 40 | styleCss: milestonesCss, | |
| 41 | 41 | body: html`${repoHeader(ctx, repo, "map")} |
| 42 | <div class="ms-top mt"> | |
| 42 | <div class="stack mt"> | |
| 43 | 43 | <p class="small muted"><a href="${back}">${p.limb.title}</a></p> |
| 44 | 44 | <h1>Re-plan “${p.limb.title}”?</h1> |
| 45 | 45 | <p> |
| @@ -50,17 +50,14 @@ export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Respons | ||
| 50 | 50 | before it starts. |
| 51 | 51 | </p> |
| 52 | 52 | </div> |
| 53 | <section class="ms-section" id="replan-issues"> | |
| 53 | <section class="mt" id="replan-issues"> | |
| 54 | 54 | <h2>${plural(p.issues.length, "issue")} the planner will be given</h2> |
| 55 | <ul class="ms-rows"> | |
| 56 | ${p.issues.map( | |
| 57 | (i) => | |
| 58 | html`<li> | |
| 59 | <a href="${base}/issues/${i.number}">${i.title}</a> | |
| 60 | <span class="muted small">#${i.number}</span> | |
| 61 | </li>`, | |
| 62 | )} | |
| 63 | </ul> | |
| 55 | ${card( | |
| 56 | html`<ul class="list"> | |
| 57 | ${p.issues.map((i) => issueRow(base, i))} | |
| 58 | </ul>`, | |
| 59 | { pad: false }, | |
| 60 | )} | |
| 64 | 61 | </section> |
| 65 | 62 | ${p.blocked !== undefined |
| 66 | 63 | ? html`<p class="mt" id="replan-refused"> |
| @@ -95,9 +92,8 @@ export function replanSubmit( | ||
| 95 | 92 | title: `Not done · ${repo.name}`, |
| 96 | 93 | crumbs: crumbsFor(repo), |
| 97 | 94 | palette: repoPalette(ctx, repo), |
| 98 | styleCss: milestonesCss, | |
| 99 | 95 | body: html`${repoHeader(ctx, repo, "map")} |
| 100 | <div class="ms-top mt"> | |
| 96 | <div class="stack mt"> | |
| 101 | 97 | <h1>${action === "replan" ? "Not handed over" : "Not taken back"}</h1> |
| 102 | 98 | <p id="replan-refused">Nothing changed: ${done.error.message}.</p> |
| 103 | 99 | <p><a href="${back}">Back</a></p> |
src/web/pages/limb_view.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** | |
| 2 | * The parts the milestone and limb pages share, drawn from the §5 | |
| 3 | * components: an issue as a row, and a list that folds its tail. No styling of their own. | |
| 4 | */ | |
| 5 | ||
| 6 | import { html, type Html } from "../html.ts"; | |
| 7 | import { card, row } from "../ui.ts"; | |
| 8 | ||
| 9 | /** An issue as a row: its title, its number, and whatever is known of it. */ | |
| 10 | export function issueRow( | |
| 11 | base: string, | |
| 12 | issue: { number: number; title: string }, | |
| 13 | meta?: Html, | |
| 14 | trail?: Html, | |
| 15 | ): Html { | |
| 16 | return row({ | |
| 17 | title: html`<a href="${base}/issues/${issue.number}">${issue.title}</a> | |
| 18 | <span class="muted small">#${issue.number}</span>`, | |
| 19 | ...(meta === undefined ? {} : { meta }), | |
| 20 | ...(trail === undefined ? {} : { trail }), | |
| 21 | }); | |
| 22 | } | |
| 23 | ||
| 24 | /** A list of rows; `wrap` lets title and meta wrap instead of ellipsizing (§5d settings rows). */ | |
| 25 | export const list = (rows: Html[], wrap = false): Html => | |
| 26 | html`<ul class="list${wrap ? " setting-list" : ""}">${rows}</ul>`; | |
| 27 | ||
| 28 | /** A card of rows, wrapping. */ | |
| 29 | export const wrappedCard = (rows: Html[]): Html => | |
| 30 | card(list(rows, true), { pad: false }); | |
| 31 | ||
| 32 | /** A card of rows that shows its first `shown` and folds the rest. */ | |
| 33 | export function folded(rows: Html[], what: string, shown: number): Html { | |
| 34 | if (rows.length <= shown) return card(list(rows), { pad: false }); | |
| 35 | return card(list(rows.slice(0, shown)), { | |
| 36 | pad: false, | |
| 37 | foot: html`<details> | |
| 38 | <summary class="small">${rows.length - shown} more ${what}</summary> | |
| 39 | ${list(rows.slice(shown))} | |
| 40 | </details>`, | |
| 41 | }); | |
| 42 | } |
src/web/pages/milestones.ts +144 −188
| @@ -51,7 +51,8 @@ import { html, when, type Html } from "../html.ts"; | ||
| 51 | 51 | import { field, readForm } from "../form.ts"; |
| 52 | 52 | import { page, redirect } from "../layout.ts"; |
| 53 | 53 | import { liveClientJs, liveMount } from "../live.ts"; |
| 54 | import { btn, time } from "../ui.ts"; | |
| 54 | import { badge, btn, card, listCard, pill, row, time } from "../ui.ts"; | |
| 55 | import { folded, issueRow, list, wrappedCard } from "./limb_view.ts"; | |
| 55 | 56 | |
| 56 | 57 | /** Where a milestone is: its own page. */ |
| 57 | 58 | export const milestoneHref = (base: string, number: number): string => |
| @@ -93,15 +94,19 @@ function moverFor( | ||
| 93 | 94 | if (seat === undefined || seats.size(group) < 2) return null; |
| 94 | 95 | const up = seat.place > 1; |
| 95 | 96 | const down = seat.place < seats.size(group); |
| 96 | return html`<span class="ms-move small" | |
| 97 | return html`<span class="bar" | |
| 97 | 98 | >${up |
| 98 | ? html`<a href="${limbHref(base, limb.number, "raise", group)}" | |
| 99 | >Raise</a | |
| 100 | >` | |
| 99 | ? btn("Raise", { | |
| 100 | href: limbHref(base, limb.number, "raise", group), | |
| 101 | size: "sm", | |
| 102 | variant: "ghost", | |
| 103 | }) | |
| 101 | 104 | : null}${down |
| 102 | ? html`<a href="${limbHref(base, limb.number, "lower", group)}" | |
| 103 | >Lower</a | |
| 104 | >` | |
| 105 | ? btn("Lower", { | |
| 106 | href: limbHref(base, limb.number, "lower", group), | |
| 107 | size: "sm", | |
| 108 | variant: "ghost", | |
| 109 | }) | |
| 105 | 110 | : null}</span |
| 106 | 111 | >`; |
| 107 | 112 | }; |
| @@ -125,7 +130,7 @@ function limbActions( | ||
| 125 | 130 | replan: Html | null, |
| 126 | 131 | ): Html { |
| 127 | 132 | const number = m.issue.number; |
| 128 | return html`<p class="bar ms-actions" id="limb-actions"> | |
| 133 | return html`<p class="bar mt" id="limb-actions"> | |
| 129 | 134 | ${lead} |
| 130 | 135 | ${move} |
| 131 | 136 | ${replan} |
| @@ -165,28 +170,28 @@ function weightWords(limb: Limb): Html { | ||
| 165 | 170 | return html`${parts.map((p, n) => html`${n > 0 ? " · " : null}${p}`)}`; |
| 166 | 171 | } |
| 167 | 172 | |
| 168 | /** One limb as one line: a link to it, how far it is, and its weight. */ | |
| 173 | /** How far a limb is, as a bar: the native element, named for a reader who cannot see it. */ | |
| 174 | const progress = (done: number, total: number, label: string): Html => | |
| 175 | html`<progress | |
| 176 | max="${Math.max(total, 1)}" | |
| 177 | value="${done}" | |
| 178 | aria-label="${label}" | |
| 179 | ></progress>`; | |
| 180 | ||
| 181 | /** One limb as one row: a link to it, how far it is, and its weight. */ | |
| 169 | 182 | function limbLine(base: string, limb: Limb, move: Html | null = null): Html { |
| 170 | 183 | const w = limb.weight; |
| 171 | return html`<li class="ms-limb" data-trim="${limb.trim === undefined ? "no" : "yes"}"> | |
| 172 | <a class="ms-name" href="${milestoneHref(base, limb.issue.number)}" | |
| 184 | return row({ | |
| 185 | title: html`<a href="${milestoneHref(base, limb.issue.number)}" | |
| 173 | 186 | >${limb.name}</a |
| 174 | > | |
| 175 | <span class="ms-count"><b>${w.done}</b> of ${w.total} done</span> | |
| 176 | <progress | |
| 177 | class="ms-bar" | |
| 178 | max="${Math.max(w.total, 1)}" | |
| 179 | value="${w.done}" | |
| 180 | aria-label="${limb.name}: ${w.done} of ${w.total} done" | |
| 181 | ></progress> | |
| 182 | <span class="ms-weight muted small">${weightWords(limb)}</span> | |
| 183 | ${when( | |
| 184 | limb.trim, | |
| 185 | (why) => | |
| 186 | html`<span class="ms-trim small">A candidate to trim: ${why}.</span>`, | |
| 187 | )} | |
| 188 | ${move} | |
| 189 | </li>`; | |
| 187 | >`, | |
| 188 | meta: html`${progress(w.done, w.total, `${limb.name}: ${w.done} of ${w.total} done`)} | |
| 189 | ${weightWords(limb)}${when( | |
| 190 | limb.trim, | |
| 191 | (why) => html` ${badge("trim", { tone: "attn" })} A candidate to trim: ${why}.`, | |
| 192 | )}`, | |
| 193 | trail: html`${pill(`${w.done} of ${w.total} done`)}${move}`, | |
| 194 | }); | |
| 190 | 195 | } |
| 191 | 196 | |
| 192 | 197 | /** GET /:owner/:name/milestones — the plan, collapsed to its limbs. */ |
| @@ -211,9 +216,13 @@ export function milestonesPage(ctx: Ctx): Response { | ||
| 211 | 216 | Nothing here waits for anything yet, so there is no milestone to |
| 212 | 217 | show. <a href="${base}/map">See the map</a>. |
| 213 | 218 | </p>` |
| 214 | : html`<ul class="ms-limbs" id="ms-limbs"> | |
| 215 | ${plan.limbs.map((limb) => limbLine(base, limb, move?.(limb.issue, "top") ?? null))} | |
| 216 | </ul>`} | |
| 219 | : html`<div id="ms-limbs"> | |
| 220 | ${wrappedCard( | |
| 221 | plan.limbs.map((limb) => | |
| 222 | limbLine(base, limb, move?.(limb.issue, "top") ?? null), | |
| 223 | ), | |
| 224 | )} | |
| 225 | </div>`} | |
| 217 | 226 | ${liveMount({ repo: base })}`; |
| 218 | 227 | return page({ |
| 219 | 228 | ctx, |
| @@ -221,11 +230,20 @@ export function milestonesPage(ctx: Ctx): Response { | ||
| 221 | 230 | crumbs: crumbsFor(repo), |
| 222 | 231 | palette: repoPalette(ctx, repo), |
| 223 | 232 | body, |
| 224 | styleCss: milestonesCss, | |
| 225 | 233 | scriptJs: liveClientJs, |
| 226 | 234 | }); |
| 227 | 235 | } |
| 228 | 236 | |
| 237 | const STATE_TONE: Record<LeafState, "success" | "attn" | undefined> = { | |
| 238 | building: "success", | |
| 239 | blocked: "attn", | |
| 240 | proposed: undefined, | |
| 241 | ready: undefined, | |
| 242 | deferred: undefined, | |
| 243 | done: undefined, | |
| 244 | gone: undefined, | |
| 245 | }; | |
| 246 | ||
| 229 | 247 | const STATE_WORDS: Record<LeafState, string> = { |
| 230 | 248 | building: "building", |
| 231 | 249 | proposed: "proposed", |
| @@ -236,16 +254,6 @@ const STATE_WORDS: Record<LeafState, string> = { | ||
| 236 | 254 | gone: "not doing", |
| 237 | 255 | }; |
| 238 | 256 | |
| 239 | /** A list that shows its first `shown` rows and folds the rest. */ | |
| 240 | function folded(rows: Html[], what: string, shown: number): Html { | |
| 241 | if (rows.length <= shown) return html`<ul class="ms-rows">${rows}</ul>`; | |
| 242 | return html`<ul class="ms-rows">${rows.slice(0, shown)}</ul> | |
| 243 | <details class="ms-more"> | |
| 244 | <summary class="small">${rows.length - shown} more ${what}</summary> | |
| 245 | <ul class="ms-rows">${rows.slice(shown)}</ul> | |
| 246 | </details>`; | |
| 247 | } | |
| 248 | ||
| 249 | 257 | /** |
| 250 | 258 | * Where a limb stands with the planner (#761): "with the planner" while it |
| 251 | 259 | * waits, with a way to take it back until the planner has been given it; |
| @@ -267,7 +275,7 @@ function replanControl( | ||
| 267 | 275 | title: "Hand this limb to the planner to re-plan as fewer, larger pieces", |
| 268 | 276 | }) |
| 269 | 277 | : null; |
| 270 | return html`<span class="ms-planner" id="ms-with-planner" | |
| 278 | return html`<span class="bar" id="ms-with-planner" | |
| 271 | 279 | ><b>With the planner</b> |
| 272 | 280 | <span class="muted small">Nothing beneath it is picked until it answers.</span></span |
| 273 | 281 | >${admin && !given(ctx.app.db, repo, pending.at) |
| @@ -286,15 +294,17 @@ function replanChanged(ctx: Ctx, repo: Repo, m: Milestone): Html | null { | ||
| 286 | 294 | const link = (n: { number: number; title: string }): Html => |
| 287 | 295 | html`<a href="${base}/issues/${n.number}">${n.title}</a> |
| 288 | 296 | <span class="muted small">#${n.number}</span>`; |
| 289 | return html`<section class="ms-section" id="ms-replanned"> | |
| 290 | <h2>Re-planned ${time(result.at)}</h2> | |
| 291 | <ul class="ms-rows"> | |
| 292 | ${result.splits.map( | |
| 293 | (s) => | |
| 294 | html`<li>${link(s)} <span class="muted small">closed as split into</span> | |
| 295 | ${s.into.map((p, n) => html`${n === 0 ? "" : ", "}${link(p)}`)}</li>`, | |
| 296 | )} | |
| 297 | </ul> | |
| 297 | return html`<section class="mt" id="ms-replanned"> | |
| 298 | <h2 class="mb">Re-planned ${time(result.at)}</h2> | |
| 299 | ${listCard( | |
| 300 | result.splits.map((s) => | |
| 301 | row({ | |
| 302 | title: link(s), | |
| 303 | meta: html`closed as split into | |
| 304 | ${s.into.map((p, n) => html`${n === 0 ? "" : ", "}${link(p)}`)}`, | |
| 305 | }), | |
| 306 | ), | |
| 307 | )} | |
| 298 | 308 | </section>`; |
| 299 | 309 | } |
| 300 | 310 | |
| @@ -332,9 +342,6 @@ function milestoneBody( | ||
| 332 | 342 | move?: Mover, |
| 333 | 343 | ): Html { |
| 334 | 344 | const base = repoBase(repo); |
| 335 | const issue = (number: number, title: string): Html => | |
| 336 | html`<a href="${base}/issues/${number}">${title}</a> | |
| 337 | <span class="muted small">#${number}</span>`; | |
| 338 | 345 | const w = m.weight; |
| 339 | 346 | // Each issue once: what is under Building now or Blocked is not listed |
| 340 | 347 | // again under Issues. |
| @@ -345,26 +352,34 @@ function milestoneBody( | ||
| 345 | 352 | const stillOpen = m.leaves.filter((l) => l.issue.state === "open"); |
| 346 | 353 | const open = stillOpen.filter((l) => !above.has(l.issue.id)); |
| 347 | 354 | const closed = m.leaves.filter((l) => l.issue.state !== "open"); |
| 348 | return html`<div class="ms-top mt"> | |
| 355 | const leafRow = (leaf: Milestone["leaves"][number], why: boolean): Html => { | |
| 356 | const tone = STATE_TONE[leaf.state]; | |
| 357 | return issueRow( | |
| 358 | base, | |
| 359 | leaf.issue, | |
| 360 | why && leaf.why !== undefined && !m.deferred | |
| 361 | ? html`${leaf.why}` | |
| 362 | : undefined, | |
| 363 | badge(STATE_WORDS[leaf.state], tone === undefined ? {} : { tone }), | |
| 364 | ); | |
| 365 | }; | |
| 366 | return html`<div class="stack mt"> | |
| 349 | 367 | <p class="small muted"> |
| 350 | 368 | <a href="${base}/milestones">Milestones</a> |
| 351 | 369 | </p> |
| 352 | 370 | <h1>${m.name}</h1> |
| 353 | <p class="ms-head" data-trim="${m.trim === undefined ? "no" : "yes"}"> | |
| 354 | <span class="ms-count"><b>${w.done}</b> of ${w.total} done</span> | |
| 355 | <progress | |
| 356 | class="ms-bar" | |
| 357 | max="${Math.max(w.total, 1)}" | |
| 358 | value="${w.done}" | |
| 359 | aria-label="${w.done} of ${w.total} done" | |
| 360 | ></progress> | |
| 361 | <span class="ms-weight muted small">${weightWords(m)}</span> | |
| 362 | </p> | |
| 371 | ${card( | |
| 372 | html`<div class="stack stack-tight"> | |
| 373 | <span><b>${w.done}</b> of ${w.total} done</span> | |
| 374 | ${progress(w.done, w.total, `${w.done} of ${w.total} done`)} | |
| 375 | <span class="muted small">${weightWords(m)}</span> | |
| 376 | </div>`, | |
| 377 | m.trim === undefined ? {} : { tone: "attn" }, | |
| 378 | )} | |
| 363 | 379 | <p id="ms-estimate">${m.estimate}</p> |
| 364 | 380 | ${when( |
| 365 | 381 | m.trim, |
| 366 | (why) => | |
| 367 | html`<p class="ms-trim small">A candidate to trim: ${why}.</p>`, | |
| 382 | (why) => html`<p class="small">A candidate to trim: ${why}.</p>`, | |
| 368 | 383 | )} |
| 369 | 384 | <p class="small muted"> |
| 370 | 385 | <a href="${base}/issues/${m.issue.number}" |
| @@ -376,56 +391,52 @@ function milestoneBody( | ||
| 376 | 391 | ${when( |
| 377 | 392 | m.limbs.length > 0, |
| 378 | 393 | () => |
| 379 | html`<section class="ms-section" id="ms-limbs"> | |
| 380 | <h2>Limbs</h2> | |
| 381 | <ul class="ms-limbs"> | |
| 382 | ${m.limbs.map((limb) => | |
| 394 | html`<section class="mt" id="ms-limbs"> | |
| 395 | <h2 class="mb">Limbs</h2> | |
| 396 | ${wrappedCard( | |
| 397 | m.limbs.map((limb) => | |
| 383 | 398 | limbLine(base, limb, move?.(limb.issue, String(m.issue.number)) ?? null), |
| 384 | )} | |
| 385 | </ul> | |
| 399 | ), | |
| 400 | )} | |
| 386 | 401 | </section>`, |
| 387 | 402 | )} |
| 388 | 403 | ${when( |
| 389 | 404 | m.building.length > 0, |
| 390 | 405 | () => |
| 391 | html`<section class="ms-section" id="ms-building"> | |
| 392 | <h2>Building now</h2> | |
| 393 | <ul class="ms-rows"> | |
| 394 | ${m.building.map( | |
| 395 | (b) => | |
| 396 | html`<li> | |
| 397 | ${issue(b.issue.number, b.issue.title)} | |
| 398 | <span class="muted small" | |
| 399 | >${names ? (b.who ?? "the crew") : "the crew"} · since | |
| 400 | ${time(b.since)}</span | |
| 401 | > | |
| 402 | </li>`, | |
| 403 | )} | |
| 404 | </ul> | |
| 406 | html`<section class="mt" id="ms-building"> | |
| 407 | <h2 class="mb">Building now</h2> | |
| 408 | ${listCard( | |
| 409 | m.building.map((b) => | |
| 410 | issueRow( | |
| 411 | base, | |
| 412 | b.issue, | |
| 413 | html`${names ? (b.who ?? "the crew") : "the crew"} · since | |
| 414 | ${time(b.since)}`, | |
| 415 | ), | |
| 416 | ), | |
| 417 | )} | |
| 405 | 418 | </section>`, |
| 406 | 419 | )} |
| 407 | 420 | ${when( |
| 408 | 421 | m.blocked.length > 0, |
| 409 | 422 | () => |
| 410 | html`<section class="ms-section" id="ms-blocked"> | |
| 411 | <h2>Blocked</h2> | |
| 423 | html`<section class="mt" id="ms-blocked"> | |
| 424 | <h2 class="mb">Blocked</h2> | |
| 412 | 425 | ${folded( |
| 413 | m.blocked.map( | |
| 414 | (b) => | |
| 415 | html`<li> | |
| 416 | ${issue(b.issue.number, b.issue.title)} | |
| 417 | <span class="muted small" | |
| 418 | >waits for | |
| 419 | ${b.on.map( | |
| 420 | (o, n) => | |
| 421 | html`${n > 0 ? ", " : null}<a | |
| 422 | href="${base}/issues/${o.number}" | |
| 423 | title="${o.title}" | |
| 424 | >#${o.number}</a | |
| 425 | >`, | |
| 426 | )}</span | |
| 427 | > | |
| 428 | </li>`, | |
| 426 | m.blocked.map((b) => | |
| 427 | issueRow( | |
| 428 | base, | |
| 429 | b.issue, | |
| 430 | html`waits for | |
| 431 | ${b.on.map( | |
| 432 | (o, n) => | |
| 433 | html`${n > 0 ? ", " : null}<a | |
| 434 | href="${base}/issues/${o.number}" | |
| 435 | title="${o.title}" | |
| 436 | >#${o.number}</a | |
| 437 | >`, | |
| 438 | )}`, | |
| 439 | ), | |
| 429 | 440 | ), |
| 430 | 441 | "blocked", |
| 431 | 442 | 5, |
| @@ -435,9 +446,9 @@ function milestoneBody( | ||
| 435 | 446 | ${when( |
| 436 | 447 | m.spend, |
| 437 | 448 | (spend) => |
| 438 | html`<section class="ms-section" id="ms-spend"> | |
| 439 | <h2>Cost and landed</h2> | |
| 440 | <p class="ms-figures"> | |
| 449 | html`<section class="mt" id="ms-spend"> | |
| 450 | <h2 class="mb">Cost and landed</h2> | |
| 451 | <p class="bar mb"> | |
| 441 | 452 | <span><b>${dollars(spend.usd)}</b> spent</span> |
| 442 | 453 | <span |
| 443 | 454 | ><b>${spend.landed}</b> change${spend.landed === 1 ? "" : "s"} |
| @@ -446,28 +457,24 @@ function milestoneBody( | ||
| 446 | 457 | </p> |
| 447 | 458 | ${spend.costliest.length === 0 |
| 448 | 459 | ? html`<p class="muted small">Nothing has cost anything yet.</p>` |
| 449 | : html`<ol class="ms-rows"> | |
| 450 | ${spend.costliest.map( | |
| 451 | (c) => | |
| 452 | html`<li> | |
| 453 | ${issue(c.issue.number, c.issue.title)} | |
| 454 | <span class="ms-state small">${dollars(c.usd)}</span> | |
| 455 | </li>`, | |
| 456 | )} | |
| 457 | </ol>`} | |
| 460 | : listCard( | |
| 461 | spend.costliest.map((c) => | |
| 462 | issueRow(base, c.issue, undefined, pill(dollars(c.usd))), | |
| 463 | ), | |
| 464 | )} | |
| 458 | 465 | </section>`, |
| 459 | 466 | )} |
| 460 | <section class="ms-section" id="ms-merged"> | |
| 461 | <h2>Merged in ${PILLAR_DAYS} days</h2> | |
| 467 | <section class="mt" id="ms-merged"> | |
| 468 | <h2 class="mb">Merged in ${PILLAR_DAYS} days</h2> | |
| 462 | 469 | ${m.merged.length === 0 |
| 463 | 470 | ? html`<p class="muted small">Nothing.</p>` |
| 464 | 471 | : folded( |
| 465 | m.merged.map( | |
| 466 | (pull) => | |
| 467 | html`<li> | |
| 468 | <a href="${base}/pulls/${pull.number}">${pull.title}</a> | |
| 469 | <span class="muted small">${time(pull.at)}</span> | |
| 470 | </li>`, | |
| 472 | m.merged.map((pull) => | |
| 473 | row({ | |
| 474 | href: `${base}/pulls/${pull.number}`, | |
| 475 | title: pull.title, | |
| 476 | meta: time(pull.at), | |
| 477 | }), | |
| 471 | 478 | ), |
| 472 | 479 | "merged", |
| 473 | 480 | 3, |
| @@ -476,46 +483,28 @@ function milestoneBody( | ||
| 476 | 483 | ${when( |
| 477 | 484 | open.length > 0 || closed.length > 0 || stillOpen.length === 0, |
| 478 | 485 | () => |
| 479 | html`<section class="ms-section" id="ms-leaves"> | |
| 480 | <h2>Issues</h2> | |
| 486 | html`<section class="mt" id="ms-leaves"> | |
| 487 | <h2 class="mb">Issues</h2> | |
| 481 | 488 | ${open.length === 0 |
| 482 | 489 | ? when( |
| 483 | 490 | stillOpen.length === 0 && m.leaves.length > 0, |
| 484 | 491 | () => html`<p class="muted small">None open.</p>`, |
| 485 | 492 | ) |
| 486 | 493 | : folded( |
| 487 | open.map( | |
| 488 | (leaf) => | |
| 489 | html`<li data-state="${leaf.state}"> | |
| 490 | ${issue(leaf.issue.number, leaf.issue.title)} | |
| 491 | <span class="ms-state small" | |
| 492 | >${STATE_WORDS[leaf.state]}${leaf.why !== undefined && | |
| 493 | !m.deferred | |
| 494 | ? ` · ${leaf.why}` | |
| 495 | : ""}</span | |
| 496 | > | |
| 497 | </li>`, | |
| 498 | ), | |
| 494 | open.map((leaf) => leafRow(leaf, true)), | |
| 499 | 495 | "open", |
| 500 | 496 | 8, |
| 501 | 497 | )} |
| 502 | 498 | ${when( |
| 503 | 499 | closed.length > 0, |
| 504 | 500 | () => |
| 505 | html`<details class="ms-more"> | |
| 506 | <summary class="small">${closed.length} closed</summary> | |
| 507 | <ul class="ms-rows"> | |
| 508 | ${closed.map( | |
| 509 | (leaf) => | |
| 510 | html`<li data-state="${leaf.state}"> | |
| 511 | ${issue(leaf.issue.number, leaf.issue.title)} | |
| 512 | <span class="ms-state small" | |
| 513 | >${STATE_WORDS[leaf.state]}</span | |
| 514 | > | |
| 515 | </li>`, | |
| 516 | )} | |
| 517 | </ul> | |
| 518 | </details>`, | |
| 501 | card( | |
| 502 | html`<details> | |
| 503 | <summary class="small">${closed.length} closed</summary> | |
| 504 | ${list(closed.map((leaf) => leafRow(leaf, false)))} | |
| 505 | </details>`, | |
| 506 | { pad: false }, | |
| 507 | ), | |
| 519 | 508 | )} |
| 520 | 509 | </section>`, |
| 521 | 510 | )}`; |
| @@ -559,7 +548,7 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 559 | 548 | ) |
| 560 | 549 | : watch === null && replan === null |
| 561 | 550 | ? null |
| 562 | : html`<p class="bar ms-actions">${watch}${replan}</p>`; | |
| 551 | : html`<p class="bar mt">${watch}${replan}</p>`; | |
| 563 | 552 | return page({ |
| 564 | 553 | ctx, |
| 565 | 554 | title: `${milestone.name} · ${repo.name}`, |
| @@ -569,7 +558,6 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 569 | 558 | ${milestoneBody(repo, milestone, owner, actions, move)} |
| 570 | 559 | ${replanChanged(ctx, repo, milestone)} |
| 571 | 560 | ${liveMount({ repo: repoBase(repo) })}`, |
| 572 | styleCss: milestonesCss, | |
| 573 | 561 | scriptJs: liveClientJs, |
| 574 | 562 | }); |
| 575 | 563 | } |
| @@ -663,35 +651,3 @@ export async function milestoneData( | ||
| 663 | 651 | : {}), |
| 664 | 652 | }; |
| 665 | 653 | } |
| 666 | ||
| 667 | export const milestonesCss = /* css */ ` | |
| 668 | .ms-limbs { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; } | |
| 669 | .ms-limb { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1px 12px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--border); } | |
| 670 | .ms-limb:last-child { border-bottom: 0; } | |
| 671 | .ms-name { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 672 | .ms-count { font-variant-numeric: tabular-nums; color: var(--muted-fg); white-space: nowrap; } | |
| 673 | .ms-count b { color: var(--fg); } | |
| 674 | .ms-bar { grid-column: 1 / -1; width: 100%; height: 4px; appearance: none; border: 0; border-radius: 2px; background: var(--border); overflow: hidden; } | |
| 675 | .ms-bar::-webkit-progress-bar { background: var(--border); } | |
| 676 | .ms-bar::-webkit-progress-value { background: var(--success); } | |
| 677 | .ms-bar::-moz-progress-bar { background: var(--success); } | |
| 678 | .ms-weight, .ms-trim { grid-column: 1 / -1; } | |
| 679 | .ms-trim { color: var(--attn); margin: 0; } | |
| 680 | .ms-top { display: grid; gap: 8px; max-width: 640px; } | |
| 681 | .ms-top h1, .ms-top p { margin: 0; } | |
| 682 | .ms-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; } | |
| 683 | .ms-actions { margin-top: 4px; } | |
| 684 | .ms-planner { display: inline-flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; } | |
| 685 | .ms-move { grid-column: 1 / -1; display: flex; gap: 14px; } | |
| 686 | .ms-section { margin-top: 22px; } | |
| 687 | .ms-section h2 { font-size: 13px; margin: 0 0 6px; } | |
| 688 | .ms-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; } | |
| 689 | .ms-rows li > span { margin-left: 6px; } | |
| 690 | .ms-figures { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 0 0 8px; font-variant-numeric: tabular-nums; } | |
| 691 | .ms-state { color: var(--muted-fg); } | |
| 692 | .ms-rows li[data-state="building"] .ms-state { color: var(--accent); } | |
| 693 | .ms-rows li[data-state="blocked"] .ms-state { color: var(--attn); } | |
| 694 | .ms-more { margin-top: 8px; } | |
| 695 | .ms-more summary { cursor: pointer; color: var(--muted-fg); } | |
| 696 | .ms-more .ms-rows { margin-top: 6px; } | |
| 697 | `; |
src/web/style/components.ts +5
| @@ -210,6 +210,11 @@ a.label:hover { color: color-mix(in srgb, var(--label) 90%, var(--fg)); } | ||
| 210 | 210 | .pill[data-tone="accent"] { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); } |
| 211 | 211 | .pill[data-tone="danger"] { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); } |
| 212 | 212 | |
| 213 | progress:not(.tests-bar) { display: block; width: 100%; height: 4px; margin: 6px 0; appearance: none; border: 0; border-radius: var(--radius-sm); background: var(--border); overflow: hidden; } | |
Global element rule with no DESIGN.md §5 row. Add the row and name the .tests-bar exclusion. |
||
| 214 | progress:not(.tests-bar)::-webkit-progress-bar { background: var(--border); } | |
| 215 | progress:not(.tests-bar)::-webkit-progress-value { background: var(--success); } | |
| 216 | progress:not(.tests-bar)::-moz-progress-bar { background: var(--success); } | |
| 217 | ||
| 213 | 218 | .dot { display: inline-block; width: 8px; height: 8px; border-radius: 999px; background: var(--faint); flex: none; } |
| 214 | 219 | .dot[data-tone="success"] { background: var(--success); } |
| 215 | 220 | .dot[data-tone="danger"] { background: var(--danger); } |
test/milestones.test.ts +7 −2
| @@ -350,9 +350,12 @@ describe("milestones", () => { | ||
| 350 | 350 | const limbs = await (await get("alice", "/alice/engine/milestones")).text(); |
| 351 | 351 | expect(limbs).toContain('id="ms-limbs"'); |
| 352 | 352 | expect(limbs).toContain( |
| 353 | `<a class="ms-name" href="/alice/engine/milestones/${trust.number}"`, | |
| 353 | `<a href="/alice/engine/milestones/${trust.number}"`, | |
| 354 | 354 | ); |
| 355 | expect(limbs).toMatch(/<b>1<\/b> of 5 done/); | |
| 355 | expect(limbs).toMatch(/<progress[^>]*aria-label="[^"]*: 1 of 5 done"/); | |
| 356 | expect(limbs).toMatch(/class="pill"[^>]*>\s*1 of 5 done/); | |
| 357 | expect(limbs).toContain("list setting-list"); | |
| 358 | expect(limbs).toMatch(/class="badge"[^>]*>\s*trim/); | |
| 356 | 359 | expect(limbs).toContain("4 open"); |
| 357 | 360 | expect(limbs).toContain("1 building"); |
| 358 | 361 | expect(limbs).toContain("$1.50"); |
| @@ -365,6 +368,8 @@ describe("milestones", () => { | ||
| 365 | 368 | ).text(); |
| 366 | 369 | expect(one).toContain("<h1>Ship the sandbox</h1>"); |
| 367 | 370 | expect(one).toMatch(/<b>1<\/b> of 3 done/); |
| 371 | expect(one).toContain('aria-label="1 of 3 done"'); | |
| 372 | expect(one).toMatch(/class="badge"[^>]*>\s*done/); | |
| 368 | 373 | expect(one).toContain( |
| 369 | 374 | "About 14 days at this week's pace: 1 closed in 7 days, 2 open.", |
| 370 | 375 | ); |
new above
offline
The badge already says trim; the sentence repeats it. Keep the reason in the title attribute or one line, not both.