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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3164-design-milestones-is-built-from-the-desi → main 7 commits fork 2 threads · 2 unresolved

  • Merged. scout/3164-design-milestones-is-built-from-the-desi was merged into main as 9273ef3.

Review

Sign in to review this change.
7 files changed +241 −257
docs/DESIGN.md +1 −1
@@ -153,6 +153,7 @@ specification.
153153 | **stat pill** `pill()` | `.pill` | `data-tone=accent\|danger` | tabular | a parenthesised count |
154154 | **status dot** `dot()` | `.dot` | `data-tone=success\|danger\|attn\|merged` | — | `●` in the tone |
155155 | **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` |
156157 | **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%` |
157158 | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name |
158159 | **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.
395396 - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals`
396397 - **`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`
397398 - **`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`
399399 - **`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`
400400 - **`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`
401401
src/web/pages/limb.ts +30 −50
@@ -32,16 +32,10 @@ import {
3232 import { field, readForm } from "../form.ts";
3333 import { html, when, type Html } from "../html.ts";
3434 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";
4336 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";
4539
4640 const WORDS: Record<LimbAction, { ask: string; does: string; go: string }> = {
4741 defer: {
@@ -90,24 +84,11 @@ const shown = (value: FleetValue): string =>
9084 const SHOWN = 10;
9185
9286 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 );
11192 }
11293
11394 /**
@@ -194,12 +175,11 @@ export function limbPreviewPage(ctx: Ctx): Response {
194175 crumbs: crumbsFor(repo),
195176 palette: repoPalette(ctx, repo),
196177 body: html`${repoHeader(ctx, repo, "map")}
197 <div class="ms-top mt">
178 <div class="stack mt">
198179 <h1>${words.ask} this limb?</h1>
199180 <p id="limb-refused">Not as one action: ${plan.error.message}.</p>
200181 <p><a href="${milestoneHref(base, number)}">Back</a></p>
201182 </div>`,
202 styleCss: milestonesCss,
203183 });
204184 }
205185 const p = plan.value;
@@ -208,7 +188,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
208188 const pillars =
209189 action === "resume" ? pillarsOf(ctx.app.db, repo).pillars : [];
210190 const body = html`${repoHeader(ctx, repo, "map")}
211 <div class="ms-top mt">
191 <div class="stack mt">
212192 <p class="small muted">
213193 <a href="${milestoneHref(base, number)}">${p.limb.title}</a>
214194 </p>
@@ -218,19 +198,21 @@ export function limbPreviewPage(ctx: Ctx): Response {
218198 ${when(
219199 p.order !== undefined,
220200 () =>
221 html`<section class="ms-section" id="limb-order">
201 html`<section class="mt" id="limb-order">
222202 <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 )}
234216 </section>`,
235217 )}
236218 ${when(
@@ -238,7 +220,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
238220 () => {
239221 const before = (p.ready?.before ?? []).map((i) => i.number);
240222 const after = p.ready?.after ?? [];
241 return html`<section class="ms-section" id="limb-ready">
223 return html`<section class="mt" id="limb-ready">
242224 <h2>The top of the ready list</h2>
243225 ${after.length === 0
244226 ? html`<p class="muted small">Nothing is ready.</p>`
@@ -253,7 +235,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
253235 ${when(
254236 p.taken.length > 0,
255237 () =>
256 html`<section class="ms-section" id="limb-taken">
238 html`<section class="mt" id="limb-taken">
257239 <h2>
258240 ${action === "resume"
259241 ? `${p.taken.length} can be picked again`
@@ -265,7 +247,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
265247 ${when(
266248 p.kept.length > 0,
267249 () =>
268 html`<section class="ms-section" id="limb-kept">
250 html`<section class="mt" id="limb-kept">
269251 <h2>
270252 ${p.kept.length} stay${p.kept.length === 1 ? "s" : ""}: something
271253 outside this limb still waits for
@@ -276,7 +258,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
276258 )}
277259 ${moving
278260 ? null
279 : html`<section class="ms-section" id="limb-rows">
261 : html`<section class="mt" id="limb-rows">
280262 <h2>What is written</h2>
281263 ${p.rows.length === 0
282264 ? 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 {
348330 crumbs: crumbsFor(repo),
349331 palette: repoPalette(ctx, repo),
350332 body,
351 styleCss: milestonesCss,
352333 });
353334 }
354335
@@ -404,7 +385,7 @@ export function limbReceiptPage(ctx: Ctx): Response {
404385 op.rows.filter((r) => r.outcome === outcome).length;
405386 const others = op.rows.length - count("ok");
406387 const body = html`${repoHeader(ctx, repo, "map")}
407 <div class="ms-top mt">
388 <div class="stack mt">
408389 <p class="small muted">
409390 <a href="${milestoneHref(base, number)}">Back to the milestone</a>
410391 </p>
@@ -413,7 +394,7 @@ export function limbReceiptPage(ctx: Ctx): Response {
413394 ${count("ok")} written${others === 0 ? "" : `, ${others} left as they were`}.
414395 </p>
415396 </div>
416 <section class="ms-section">
397 <section class="mt">
417398 <h2>The receipt</h2>
418399 ${rowList(ctx, base, op.rows)}
419400 </section>
@@ -439,7 +420,6 @@ export function limbReceiptPage(ctx: Ctx): Response {
439420 crumbs: crumbsFor(repo),
440421 palette: repoPalette(ctx, repo),
441422 body,
442 styleCss: milestonesCss,
443423 });
444424 }
445425
src/web/pages/limb_replan.ts +12 −16
@@ -22,8 +22,9 @@ import {
2222 import { html } from "../html.ts";
2323 import { page, redirect } from "../layout.ts";
2424 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";
2728
2829 /** GET /:owner/:name/milestones/:number/limb?action=replan — what the planner will be given. */
2930 export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Response {
@@ -37,9 +38,8 @@ export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Respons
3738 title: `Re-plan ${p.limb.title} · ${repo.name}`,
3839 crumbs: crumbsFor(repo),
3940 palette: repoPalette(ctx, repo),
40 styleCss: milestonesCss,
4141 body: html`${repoHeader(ctx, repo, "map")}
42 <div class="ms-top mt">
42 <div class="stack mt">
4343 <p class="small muted"><a href="${back}">${p.limb.title}</a></p>
4444 <h1>Re-plan “${p.limb.title}”?</h1>
4545 <p>
@@ -50,17 +50,14 @@ export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Respons
5050 before it starts.
5151 </p>
5252 </div>
53 <section class="ms-section" id="replan-issues">
53 <section class="mt" id="replan-issues">
5454 <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 )}
6461 </section>
6562 ${p.blocked !== undefined
6663 ? html`<p class="mt" id="replan-refused">
@@ -95,9 +92,8 @@ export function replanSubmit(
9592 title: `Not done · ${repo.name}`,
9693 crumbs: crumbsFor(repo),
9794 palette: repoPalette(ctx, repo),
98 styleCss: milestonesCss,
9995 body: html`${repoHeader(ctx, repo, "map")}
100 <div class="ms-top mt">
96 <div class="stack mt">
10197 <h1>${action === "replan" ? "Not handed over" : "Not taken back"}</h1>
10298 <p id="replan-refused">Nothing changed: ${done.error.message}.</p>
10399 <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";
5151 import { field, readForm } from "../form.ts";
5252 import { page, redirect } from "../layout.ts";
5353 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";
5556
5657 /** Where a milestone is: its own page. */
5758 export const milestoneHref = (base: string, number: number): string =>
@@ -93,15 +94,19 @@ function moverFor(
9394 if (seat === undefined || seats.size(group) < 2) return null;
9495 const up = seat.place > 1;
9596 const down = seat.place < seats.size(group);
96 return html`<span class="ms-move small"
97 return html`<span class="bar"
9798 >${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 })
101104 : 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 })
105110 : null}</span
106111 >`;
107112 };
@@ -125,7 +130,7 @@ function limbActions(
125130 replan: Html | null,
126131 ): Html {
127132 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">
129134 ${lead}
130135 ${move}
131136 ${replan}
@@ -165,28 +170,28 @@ function weightWords(limb: Limb): Html {
165170 return html`${parts.map((p, n) => html`${n > 0 ? " · " : null}${p}`)}`;
166171 }
167172
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. */
169182 function limbLine(base: string, limb: Limb, move: Html | null = null): Html {
170183 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)}"
173186 >${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`)}
outdated

The badge already says trim; the sentence repeats it. Keep the reason in the title attribute or one line, not both.

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 });
190195 }
191196
192197 /** GET /:owner/:name/milestones — the plan, collapsed to its limbs. */
@@ -211,9 +216,13 @@ export function milestonesPage(ctx: Ctx): Response {
211216 Nothing here waits for anything yet, so there is no milestone to
212217 show. <a href="${base}/map">See the map</a>.
213218 </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>`}
217226 ${liveMount({ repo: base })}`;
218227 return page({
219228 ctx,
@@ -221,11 +230,20 @@ export function milestonesPage(ctx: Ctx): Response {
221230 crumbs: crumbsFor(repo),
222231 palette: repoPalette(ctx, repo),
223232 body,
224 styleCss: milestonesCss,
225233 scriptJs: liveClientJs,
226234 });
227235 }
228236
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
229247 const STATE_WORDS: Record<LeafState, string> = {
230248 building: "building",
231249 proposed: "proposed",
@@ -236,16 +254,6 @@ const STATE_WORDS: Record<LeafState, string> = {
236254 gone: "not doing",
237255 };
238256
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
249257 /**
250258 * Where a limb stands with the planner (#761): "with the planner" while it
251259 * waits, with a way to take it back until the planner has been given it;
@@ -267,7 +275,7 @@ function replanControl(
267275 title: "Hand this limb to the planner to re-plan as fewer, larger pieces",
268276 })
269277 : null;
270 return html`<span class="ms-planner" id="ms-with-planner"
278 return html`<span class="bar" id="ms-with-planner"
271279 ><b>With the planner</b>
272280 <span class="muted small">Nothing beneath it is picked until it answers.</span></span
273281 >${admin && !given(ctx.app.db, repo, pending.at)
@@ -286,15 +294,17 @@ function replanChanged(ctx: Ctx, repo: Repo, m: Milestone): Html | null {
286294 const link = (n: { number: number; title: string }): Html =>
287295 html`<a href="${base}/issues/${n.number}">${n.title}</a>
288296 <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 )}
298308 </section>`;
299309 }
300310
@@ -332,9 +342,6 @@ function milestoneBody(
332342 move?: Mover,
333343 ): Html {
334344 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>`;
338345 const w = m.weight;
339346 // Each issue once: what is under Building now or Blocked is not listed
340347 // again under Issues.
@@ -345,26 +352,34 @@ function milestoneBody(
345352 const stillOpen = m.leaves.filter((l) => l.issue.state === "open");
346353 const open = stillOpen.filter((l) => !above.has(l.issue.id));
347354 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">
349367 <p class="small muted">
350368 <a href="${base}/milestones">Milestones</a>
351369 </p>
352370 <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 )}
363379 <p id="ms-estimate">${m.estimate}</p>
364380 ${when(
365381 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>`,
368383 )}
369384 <p class="small muted">
370385 <a href="${base}/issues/${m.issue.number}"
@@ -376,56 +391,52 @@ function milestoneBody(
376391 ${when(
377392 m.limbs.length > 0,
378393 () =>
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) =>
383398 limbLine(base, limb, move?.(limb.issue, String(m.issue.number)) ?? null),
384 )}
385 </ul>
399 ),
400 )}
386401 </section>`,
387402 )}
388403 ${when(
389404 m.building.length > 0,
390405 () =>
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 )}
405418 </section>`,
406419 )}
407420 ${when(
408421 m.blocked.length > 0,
409422 () =>
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>
412425 ${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 ),
429440 ),
430441 "blocked",
431442 5,
@@ -435,9 +446,9 @@ function milestoneBody(
435446 ${when(
436447 m.spend,
437448 (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">
441452 <span><b>${dollars(spend.usd)}</b> spent</span>
442453 <span
443454 ><b>${spend.landed}</b> change${spend.landed === 1 ? "" : "s"}
@@ -446,28 +457,24 @@ function milestoneBody(
446457 </p>
447458 ${spend.costliest.length === 0
448459 ? 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 )}
458465 </section>`,
459466 )}
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>
462469 ${m.merged.length === 0
463470 ? html`<p class="muted small">Nothing.</p>`
464471 : 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 }),
471478 ),
472479 "merged",
473480 3,
@@ -476,46 +483,28 @@ function milestoneBody(
476483 ${when(
477484 open.length > 0 || closed.length > 0 || stillOpen.length === 0,
478485 () =>
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>
481488 ${open.length === 0
482489 ? when(
483490 stillOpen.length === 0 && m.leaves.length > 0,
484491 () => html`<p class="muted small">None open.</p>`,
485492 )
486493 : 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)),
499495 "open",
500496 8,
501497 )}
502498 ${when(
503499 closed.length > 0,
504500 () =>
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 ),
519508 )}
520509 </section>`,
521510 )}`;
@@ -559,7 +548,7 @@ export function milestonePage(ctx: Ctx): Response {
559548 )
560549 : watch === null && replan === null
561550 ? null
562 : html`<p class="bar ms-actions">${watch}${replan}</p>`;
551 : html`<p class="bar mt">${watch}${replan}</p>`;
563552 return page({
564553 ctx,
565554 title: `${milestone.name} · ${repo.name}`,
@@ -569,7 +558,6 @@ export function milestonePage(ctx: Ctx): Response {
569558 ${milestoneBody(repo, milestone, owner, actions, move)}
570559 ${replanChanged(ctx, repo, milestone)}
571560 ${liveMount({ repo: repoBase(repo) })}`,
572 styleCss: milestonesCss,
573561 scriptJs: liveClientJs,
574562 });
575563 }
@@ -663,35 +651,3 @@ export async function milestoneData(
663651 : {}),
664652 };
665653 }
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)); }
210210 .pill[data-tone="accent"] { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
211211 .pill[data-tone="danger"] { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
212212
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
213218 .dot { display: inline-block; width: 8px; height: 8px; border-radius: 999px; background: var(--faint); flex: none; }
214219 .dot[data-tone="success"] { background: var(--success); }
215220 .dot[data-tone="danger"] { background: var(--danger); }
test/milestones.test.ts +7 −2
@@ -350,9 +350,12 @@ describe("milestones", () => {
350350 const limbs = await (await get("alice", "/alice/engine/milestones")).text();
351351 expect(limbs).toContain('id="ms-limbs"');
352352 expect(limbs).toContain(
353 `<a class="ms-name" href="/alice/engine/milestones/${trust.number}"`,
353 `<a href="/alice/engine/milestones/${trust.number}"`,
354354 );
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/);
356359 expect(limbs).toContain("4 open");
357360 expect(limbs).toContain("1 building");
358361 expect(limbs).toContain("$1.50");
@@ -365,6 +368,8 @@ describe("milestones", () => {
365368 ).text();
366369 expect(one).toContain("<h1>Ship the sandbox</h1>");
367370 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/);
368373 expect(one).toContain(
369374 "About 14 days at this week&#39;s pace: 1 closed in 7 days, 2 open.",
370375 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs