Public: anyone can read this; the crew can change it.
Design: acting on a limb uses the design system's row and dialog, and limb-* leave §5d #3188
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3185-design-acting-on-a-limb-uses-the-design → main 1 commit fork
4 files
changed +104
−86
docs/DESIGN.md −1
| @@ -392,7 +392,6 @@ gone, so this list only shrinks. | ||
| 392 | 392 | - **`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` |
| 393 | 393 | - **`pages/board.ts`** — the board, the live wall of the crews at work: `.board` `.board-about` `.board-answer` `.board-app` `.board-app-mode` `.board-app-name` `.board-app-nums` `.board-app-week` `.board-asked` `.board-asks` `.board-bar` `.board-brand` `.board-card` `.board-clock` `.board-count` `.board-decision` `.board-decision-main` `.board-declined` `.board-declined-row` `.board-declined-says` `.board-dot` `.board-elapsed` `.board-fill` `.board-forge` `.board-grid` `.board-group` `.board-h` `.board-inferred` `.board-key` `.board-keys` `.board-kind` `.board-landed` `.board-landed-meta` `.board-landed-title` `.board-list` `.board-live` `.board-outcome` `.board-p` `.board-parked-row` `.board-pillar` `.board-pillars` `.board-pillars-h` `.board-quiet` `.board-region` `.board-run` `.board-run-meta` `.board-run-title` `.board-run-who` `.board-scope` `.board-spend` `.board-spend-detail` `.board-state` `.board-sub` `.board-sub-name` `.board-sub-scope` `.board-subs` `.board-thumb` `.board-top` `.board-when` `.board-window` `.board-window-name` `.board-windows` `.spend-table` `.spend-totals` |
| 394 | 394 | - **`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` |
| 395 | - **`pages/limb.ts`** — acting on a whole limb from its milestone: `.limb-confirm` `.limb-rows` `.limb-why` | |
| 396 | 395 | - **`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` |
| 397 | 396 | - **`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` |
| 398 | 397 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard` `.fcard-acts` `.fcard-form` `.fcard-head` `.fcard-more` `.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/limb.ts +83 −82
| @@ -32,7 +32,14 @@ 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 { btn } from "../ui.ts"; | |
| 35 | import { | |
| 36 | badge, | |
| 37 | btn, | |
| 38 | field as formField, | |
| 39 | listCard, | |
| 40 | row, | |
| 41 | selectField, | |
| 42 | } from "../ui.ts"; | |
| 36 | 43 | import { replanPreviewPage, replanSubmit } from "./limb_replan.ts"; |
| 37 | 44 | import { milestoneHref, milestonesCss } from "./milestones.ts"; |
| 38 | 45 | |
| @@ -123,27 +130,24 @@ function rowList( | ||
| 123 | 130 | ctx.app.db, |
| 124 | 131 | rows.map((r) => r.targetId), |
| 125 | 132 | ); |
| 126 | return html`<ul class="ms-rows limb-rows"> | |
| 127 | ${rows.map((r) => { | |
| 133 | return listCard( | |
| 134 | rows.map((r) => { | |
| 128 | 135 | const issue = issues.get(r.targetId); |
| 129 | return html`<li data-outcome="${r.outcome}"> | |
| 130 | ${issue === undefined | |
| 131 | ? html`<b>${r.label}</b>` | |
| 132 | : html`<a href="${base}/issues/${issue.number}">${issue.title}</a> | |
| 133 | <span class="muted small">#${issue.number}</span>`} | |
| 134 | <span class="muted small" | |
| 135 | >${shown(r.before)} → ${shown(r.after)}</span | |
| 136 | > | |
| 137 | ${when( | |
| 138 | r.outcome !== "change" && r.outcome !== "ok", | |
| 139 | () => | |
| 140 | html`<span class="limb-why small" | |
| 141 | >${r.outcome}${r.reason === "" ? "" : `: ${r.reason}`}</span | |
| 142 | >`, | |
| 143 | )} | |
| 144 | </li>`; | |
| 145 | })} | |
| 146 | </ul>`; | |
| 136 | const settled = r.outcome === "change" || r.outcome === "ok"; | |
| 137 | const meta = [ | |
| 138 | ...(issue === undefined ? [] : [`#${issue.number}`]), | |
| 139 | `${shown(r.before)} → ${shown(r.after)}`, | |
| 140 | ...(settled || r.reason === "" ? [] : [r.reason]), | |
| 141 | ].join(" · "); | |
| 142 | return row({ | |
| 143 | ...(issue === undefined | |
| 144 | ? { title: r.label } | |
| 145 | : { href: `${base}/issues/${issue.number}`, title: issue.title }), | |
| 146 | meta: html`<span title="${meta}">${meta}</span>`, | |
| 147 | ...(settled ? {} : { trail: badge(r.outcome, { tone: "attn" }) }), | |
| 148 | }); | |
| 149 | }), | |
| 150 | ); | |
| 147 | 151 | } |
| 148 | 152 | |
| 149 | 153 | function requestOf( |
| @@ -195,7 +199,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 195 | 199 | <p id="limb-refused">Not as one action: ${plan.error.message}.</p> |
| 196 | 200 | <p><a href="${milestoneHref(base, number)}">Back</a></p> |
| 197 | 201 | </div>`, |
| 198 | styleCss: `${milestonesCss}${limbCss}`, | |
| 202 | styleCss: milestonesCss, | |
| 199 | 203 | }); |
| 200 | 204 | } |
| 201 | 205 | const p = plan.value; |
| @@ -279,57 +283,61 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 279 | 283 | : rowList(ctx, base, p.rows)} |
| 280 | 284 | </section>`} |
| 281 | 285 | ${willWrite |
| 282 | ? html`<form | |
| 283 | method="post" | |
| 284 | action="${milestoneHref(base, number)}/limb" | |
| 285 | class="limb-confirm mt" | |
| 286 | > | |
| 287 | <input type="hidden" name="action" value="${action}" /> | |
| 288 | ${when( | |
| 289 | request.under, | |
| 290 | (under) => html`<input type="hidden" name="under" value="${under}" />`, | |
| 291 | )} | |
| 292 | ${when( | |
| 293 | action === "close", | |
| 294 | () => | |
| 295 | html`<label class="small" for="limb-reason" | |
| 296 | >Why, in one sentence</label | |
| 297 | > | |
| 298 | <input | |
| 299 | class="input" | |
| 300 | id="limb-reason" | |
| 301 | name="reason" | |
| 302 | required | |
| 303 | maxlength="300" | |
| 304 | />`, | |
| 305 | )} | |
| 306 | ${when( | |
| 307 | action === "resume", | |
| 308 | () => | |
| 309 | html`<label class="small" for="limb-pillar">Under</label> | |
| 310 | <select class="input" id="limb-pillar" name="pillar"> | |
| 311 | <option value="">no pillar</option> | |
| 312 | ${pillars.map( | |
| 313 | (pillar) => | |
| 314 | html`<option | |
| 315 | value="${pillar.short}" | |
| 316 | ${request.pillar?.toLowerCase() === | |
| 317 | pillar.short.toLowerCase() | |
| 318 | ? html`selected` | |
| 319 | : null} | |
| 320 | > | |
| 321 | ${pillar.name} | |
| 322 | </option>`, | |
| 323 | )} | |
| 324 | </select>`, | |
| 325 | )} | |
| 326 | <div class="bar"> | |
| 327 | ${btn(words.go, { | |
| 328 | variant: action === "close" ? "danger" : "primary", | |
| 329 | })} | |
| 330 | ${btn("Back", { href: milestoneHref(base, number), variant: "ghost" })} | |
| 331 | </div> | |
| 332 | </form>` | |
| 286 | ? html`<div class="mt"> | |
| 287 | <form | |
| 288 | method="post" | |
| 289 | action="${milestoneHref(base, number)}/limb" | |
| 290 | class="dialog" | |
| 291 | aria-labelledby="limb-confirm-title" | |
| 292 | > | |
| 293 | <div class="dialog-head"> | |
| 294 | <h2 id="limb-confirm-title">${words.go}?</h2> | |
| 295 | </div> | |
| 296 | <div class="dialog-body stack"> | |
| 297 | <input type="hidden" name="action" value="${action}" /> | |
| 298 | ${when( | |
| 299 | request.under, | |
| 300 | (under) => | |
| 301 | html`<input type="hidden" name="under" value="${under}" />`, | |
| 302 | )} | |
| 303 | ${when(action === "close", () => | |
| 304 | formField({ | |
| 305 | label: "Why, in one sentence", | |
| 306 | name: "reason", | |
| 307 | id: "limb-reason", | |
| 308 | required: true, | |
| 309 | maxlength: 300, | |
| 310 | }), | |
| 311 | )} | |
| 312 | ${when(action === "resume", () => | |
| 313 | selectField({ | |
| 314 | label: "Under", | |
| 315 | name: "pillar", | |
| 316 | id: "limb-pillar", | |
| 317 | options: [ | |
| 318 | { value: "", label: "no pillar" }, | |
| 319 | ...pillars.map((pillar) => ({ | |
| 320 | value: pillar.short, | |
| 321 | label: pillar.name, | |
| 322 | selected: | |
| 323 | request.pillar?.toLowerCase() === | |
| 324 | pillar.short.toLowerCase(), | |
| 325 | })), | |
| 326 | ], | |
| 327 | }), | |
| 328 | )} | |
| 329 | </div> | |
| 330 | <div class="dialog-foot"> | |
| 331 | ${btn("Back", { | |
| 332 | href: milestoneHref(base, number), | |
| 333 | variant: "ghost", | |
| 334 | })} | |
| 335 | ${btn(words.go, { | |
| 336 | variant: action === "close" ? "danger" : "primary", | |
| 337 | })} | |
| 338 | </div> | |
| 339 | </form> | |
| 340 | </div>` | |
| 333 | 341 | : html`<p class="mt"> |
| 334 | 342 | Nothing here is yours to change. |
| 335 | 343 | <a href="${milestoneHref(base, number)}">Back</a> |
| @@ -340,7 +348,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 340 | 348 | crumbs: crumbsFor(repo), |
| 341 | 349 | palette: repoPalette(ctx, repo), |
| 342 | 350 | body, |
| 343 | styleCss: `${milestonesCss}${limbCss}`, | |
| 351 | styleCss: milestonesCss, | |
| 344 | 352 | }); |
| 345 | 353 | } |
| 346 | 354 | |
| @@ -431,7 +439,7 @@ export function limbReceiptPage(ctx: Ctx): Response { | ||
| 431 | 439 | crumbs: crumbsFor(repo), |
| 432 | 440 | palette: repoPalette(ctx, repo), |
| 433 | 441 | body, |
| 434 | styleCss: `${milestonesCss}${limbCss}`, | |
| 442 | styleCss: milestonesCss, | |
| 435 | 443 | }); |
| 436 | 444 | } |
| 437 | 445 | |
| @@ -450,10 +458,3 @@ export async function limbUndo(ctx: Ctx): Promise<Response> { | ||
| 450 | 458 | `${milestoneHref(base, number)}/limb/${undone.ok ? undone.value.id : (ctx.params.op ?? "")}`, |
| 451 | 459 | ); |
| 452 | 460 | } |
| 453 | ||
| 454 | const limbCss = /* css */ ` | |
| 455 | .limb-rows li[data-outcome="refused"], .limb-rows li[data-outcome="failed"] { color: var(--muted-fg); } | |
| 456 | .limb-why { color: var(--attn); } | |
| 457 | .limb-confirm { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; max-width: 520px; } | |
| 458 | .limb-confirm .input { width: 100%; box-sizing: border-box; } | |
| 459 | `; |
src/web/ui.ts +2
| @@ -1007,6 +1007,7 @@ export interface FieldOptions { | ||
| 1007 | 1007 | placeholder?: string; |
| 1008 | 1008 | pattern?: string; |
| 1009 | 1009 | minlength?: number; |
| 1010 | maxlength?: number; | |
| 1010 | 1011 | rows?: number; |
| 1011 | 1012 | mono?: boolean; |
| 1012 | 1013 | id?: string; |
| @@ -1038,6 +1039,7 @@ ${o.value ?? ""}</textarea | ||
| 1038 | 1039 | ${o.autocomplete ? html`autocomplete="${o.autocomplete}"` : null} |
| 1039 | 1040 | ${o.pattern ? html`pattern="${o.pattern}"` : null} |
| 1040 | 1041 | ${o.minlength !== undefined ? html`minlength="${o.minlength}"` : null} |
| 1042 | ${o.maxlength !== undefined ? html`maxlength="${o.maxlength}"` : null} | |
| 1041 | 1043 | />`; |
| 1042 | 1044 | return html`<div class="field" ${o.error ? raw("data-invalid") : null}> |
| 1043 | 1045 | <label for="${id}">${o.label}</label> |
test/limbs.test.ts +19 −3
| @@ -719,14 +719,30 @@ describe("limb actions", () => { | ||
| 719 | 719 | expect(preview).toContain("4 stop being picked"); |
| 720 | 720 | expect(preview).toMatch(/1 stays: something\s+outside this limb still waits for\s+it/); |
| 721 | 721 | expect(preview).toContain("A shared toolbar"); |
| 722 | // The row is the issue by its title, and where it goes. | |
| 723 | expect(preview).toContain( | |
| 724 | `<a href="/alice/engine/issues/${iface.number}">One interface</a>`, | |
| 722 | // The row is the §5 row: the issue by its title, and where it goes. | |
| 723 | expect(preview).toMatch( | |
| 724 | new RegExp( | |
| 725 | `<a href="/alice/engine/issues/${iface.number}"\\s+class="row"[^>]*>[\\s\\S]*?<span\\s+class="row-title"[^>]*>One interface</span`, | |
| 726 | ), | |
| 725 | 727 | ); |
| 726 | 728 | expect(preview).toContain( |
| 727 | 729 | `under #${feed.number} → behind #${gate.number}, under #${later.number}`, |
| 728 | 730 | ); |
| 731 | // The confirm is the §5 dialog: a same-origin POST to the limb, nothing | |
| 732 | // written until it is sent. | |
| 733 | expect(preview).toMatch( | |
| 734 | new RegExp( | |
| 735 | `<form\\s+method="post"\\s+action="${at}/limb"\\s+class="dialog"`, | |
| 736 | ), | |
| 737 | ); | |
| 738 | expect(preview).toContain('<h2 id="limb-confirm-title">Defer this limb?</h2>'); | |
| 729 | 739 | expect(preview).toContain("Defer this limb"); |
| 740 | expect(preview).not.toContain('class="limb-'); | |
| 741 | // A close asks its reason in the dialog, as a §5 field. | |
| 742 | const closing = await (await get("alice", `${at}/limb?action=close`)).text(); | |
| 743 | expect(closing).toMatch(/<label for="limb-reason">Why, in one sentence<\/label>/); | |
| 744 | expect(closing).toMatch(/id="limb-reason"\s+name="reason"\s+required/); | |
| 745 | expect(closing).toContain('maxlength="300"'); | |
| 730 | 746 | // A reader's preview says so, and offers nothing to confirm. |
| 731 | 747 | const looked = await (await get("bob", `${at}/limb?action=defer`)).text(); |
| 732 | 748 | expect(looked).toContain("Nothing here is yours to change."); |
new above
offline