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

Merged opened this

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

Merged

mason/forge:mason/3185-design-acting-on-a-limb-uses-the-design → main 1 commit fork

  • Merged. mason/3185-design-acting-on-a-limb-uses-the-design was merged into main as 0b25cc3.

Review

Sign in to review this change.
4 files changed +104 −86
docs/DESIGN.md −1
@@ -392,7 +392,6 @@ gone, so this list only shrinks.
392392 - **`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`
393393 - **`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`
394394 - **`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`
396395 - **`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`
397396 - **`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`
398397 - **`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 {
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 { btn } from "../ui.ts";
35 import {
36 badge,
37 btn,
38 field as formField,
39 listCard,
40 row,
41 selectField,
42 } from "../ui.ts";
3643 import { replanPreviewPage, replanSubmit } from "./limb_replan.ts";
3744 import { milestoneHref, milestonesCss } from "./milestones.ts";
3845
@@ -123,27 +130,24 @@ function rowList(
123130 ctx.app.db,
124131 rows.map((r) => r.targetId),
125132 );
126 return html`<ul class="ms-rows limb-rows">
127 ${rows.map((r) => {
133 return listCard(
134 rows.map((r) => {
128135 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 );
147151 }
148152
149153 function requestOf(
@@ -195,7 +199,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
195199 <p id="limb-refused">Not as one action: ${plan.error.message}.</p>
196200 <p><a href="${milestoneHref(base, number)}">Back</a></p>
197201 </div>`,
198 styleCss: `${milestonesCss}${limbCss}`,
202 styleCss: milestonesCss,
199203 });
200204 }
201205 const p = plan.value;
@@ -279,57 +283,61 @@ export function limbPreviewPage(ctx: Ctx): Response {
279283 : rowList(ctx, base, p.rows)}
280284 </section>`}
281285 ${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>`
333341 : html`<p class="mt">
334342 Nothing here is yours to change.
335343 <a href="${milestoneHref(base, number)}">Back</a>
@@ -340,7 +348,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
340348 crumbs: crumbsFor(repo),
341349 palette: repoPalette(ctx, repo),
342350 body,
343 styleCss: `${milestonesCss}${limbCss}`,
351 styleCss: milestonesCss,
344352 });
345353 }
346354
@@ -431,7 +439,7 @@ export function limbReceiptPage(ctx: Ctx): Response {
431439 crumbs: crumbsFor(repo),
432440 palette: repoPalette(ctx, repo),
433441 body,
434 styleCss: `${milestonesCss}${limbCss}`,
442 styleCss: milestonesCss,
435443 });
436444 }
437445
@@ -450,10 +458,3 @@ export async function limbUndo(ctx: Ctx): Promise<Response> {
450458 `${milestoneHref(base, number)}/limb/${undone.ok ? undone.value.id : (ctx.params.op ?? "")}`,
451459 );
452460 }
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 {
10071007 placeholder?: string;
10081008 pattern?: string;
10091009 minlength?: number;
1010 maxlength?: number;
10101011 rows?: number;
10111012 mono?: boolean;
10121013 id?: string;
@@ -1038,6 +1039,7 @@ ${o.value ?? ""}</textarea
10381039 ${o.autocomplete ? html`autocomplete="${o.autocomplete}"` : null}
10391040 ${o.pattern ? html`pattern="${o.pattern}"` : null}
10401041 ${o.minlength !== undefined ? html`minlength="${o.minlength}"` : null}
1042 ${o.maxlength !== undefined ? html`maxlength="${o.maxlength}"` : null}
10411043 />`;
10421044 return html`<div class="field" ${o.error ? raw("data-invalid") : null}>
10431045 <label for="${id}">${o.label}</label>
test/limbs.test.ts +19 −3
@@ -719,14 +719,30 @@ describe("limb actions", () => {
719719 expect(preview).toContain("4 stop being picked");
720720 expect(preview).toMatch(/1 stays: something\s+outside this limb still waits for\s+it/);
721721 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 ),
725727 );
726728 expect(preview).toContain(
727729 `under #${feed.number} → behind #${gate.number}, under #${later.number}`,
728730 );
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>');
729739 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"');
730746 // A reader's preview says so, and offers nothing to confirm.
731747 const looked = await (await get("bob", `${at}/limb?action=defer`)).text();
732748 expect(looked).toContain("Nothing here is yours to change.");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs