Public: anyone can read this; the crew can change it.
The inbox's ask cards draw §5's card and buttons #4658
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4615-the-inbox-s-ask-cards-draw-5-s-card-and → main 4 commits fork
5 files
changed +19
−105
docs/DESIGN.md −1
| @@ -400,7 +400,6 @@ gone, so this list only shrinks. | ||
| 400 | 400 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 401 | 401 | - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-hidden` `.rc-resolve` `.rc-resolved` `.wd` |
| 402 | 402 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**). |
| 403 | - **`style/content.ts`, search results** — search results, the inbox's ask cards: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` | |
| 404 | 403 | - **`pages/board.ts`** — the board itself is §5's **board**; the spend tables `spend.ts` draws wear §5's `.table` and `.stack`, no class of their own |
| 405 | 404 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-main` `.map-meta` `.map-node` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` |
| 406 | 405 | - **`studio/feed_style.ts`** — the home feed's cards and its composer (the apps rail is §5's card, row and btn; an admin's tick boxes and Archive are always shown, deliberately, with no Select toggle): `.composer` `.feed` `.feed-head` |
src/web/studio/asks_page.ts +10 −33
| @@ -79,9 +79,8 @@ const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "st | ||
| 79 | 79 | /** A row's box for the bulk form, which sits apart (`form="asks-bulk"`): forms do not nest. */ |
| 80 | 80 | const pick = (d: Decision, picked: ReadonlySet<string>): Html | null => |
| 81 | 81 | BULK_KINDS.has(d.kind) |
| 82 | ? html`<label class="ask-pick-hit"><input | |
| 82 | ? html`<label class="check"><input | |
| 83 | 83 | type="checkbox" |
| 84 | class="ask-pick" | |
| 85 | 84 | name="item" |
| 86 | 85 | value="${d.id}" |
| 87 | 86 | form="asks-bulk" |
| @@ -163,7 +162,7 @@ const clause = ( | ||
| 163 | 162 | which?: string, |
| 164 | 163 | ): Html => |
| 165 | 164 | html`<p> |
| 166 | ${weight(c, which)}${d === undefined ? null : html`<span class="ask-short">${d.short.charAt(0).toUpperCase()}${d.short.slice(1)}. </span>`}<span class="muted" | |
| 165 | ${weight(c, which)}${d === undefined ? null : html`${d.short.charAt(0).toUpperCase()}${d.short.slice(1)}. `}<span class="muted" | |
| 167 | 166 | >If nobody acts, ${ASK_WORDS[kind].ifNothing}.</span |
| 168 | 167 | > |
| 169 | 168 | </p>`; |
| @@ -182,22 +181,10 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = { | ||
| 182 | 181 | assigned: "Assigned", |
| 183 | 182 | }; |
| 184 | 183 | |
| 185 | /** | |
| 186 | * An ask's actions: the one that decides, then the rest. A wide screen | |
| 187 | * lays them in a line; a phone puts the decider at the trailing edge, 44 px | |
| 188 | * tall, and the rest behind one tap ("More"). | |
| 189 | */ | |
| 184 | /** An ask's actions in a line: the one that decides, then the rest. */ | |
| 190 | 185 | function acts(ctx: Ctx, d: Decision): Html { |
| 191 | 186 | const { primary, others } = decisionButtons(ctx, d); |
| 192 | return html`<div class="ask-acts"> | |
| 193 | ${primary} | |
| 194 | ${others.length === 0 | |
| 195 | ? null | |
| 196 | : html`<details class="ask-more-acts"> | |
| 197 | <summary class="btn" data-size="sm">More</summary> | |
| 198 | ${others} | |
| 199 | </details>`} | |
| 200 | </div>`; | |
| 187 | return html`<div class="bar mt">${primary}${others}</div>`; | |
| 201 | 188 | } |
| 202 | 189 | |
| 203 | 190 | /** What the crew advised on a pull waiting for a person, in one plain line; nothing when it wrote none. */ |
| @@ -237,11 +224,9 @@ function decisionCard(ctx: Ctx, d: Decision, picked: ReadonlySet<string>): Html | ||
| 237 | 224 | ${acts(ctx, d)}`, |
| 238 | 225 | { |
| 239 | 226 | id: askAnchor(d.id), |
| 240 | extra: "ask-card", | |
| 241 | 227 | head: html`${pick(d, picked)} |
| 242 | 228 | <h2>${title}</h2> |
| 243 | <span class="ask-meta muted small" | |
| 244 | ><b class="ask-kind">${KIND_LABEL[d.kind]}</b>${d.repo.ownerName}/${d.repo.name}</span | |
| 229 | <span class="muted small"><b>${KIND_LABEL[d.kind]}</b> · ${d.repo.ownerName}/${d.repo.name}</span | |
| 245 | 230 | >${waitChip(d.waiting)}`, |
| 246 | 231 | }, |
| 247 | 232 | ); |
| @@ -249,7 +234,7 @@ function decisionCard(ctx: Ctx, d: Decision, picked: ReadonlySet<string>): Html | ||
| 249 | 234 | |
| 250 | 235 | /** What the row waits for and on whom: "ready to land · on you". */ |
| 251 | 236 | const waitChip = (w: WaitingFor): Html => |
| 252 | html`<span class="ask-waiting small" data-waiting="${w.word}" | |
| 237 | html`<span class="badge" data-variant="outline" data-waiting="${w.word}" | |
| 253 | 238 | ><span class="sr-only">waiting for </span>${w.word} · on ${w.on}</span |
| 254 | 239 | >`; |
| 255 | 240 | |
| @@ -290,24 +275,16 @@ function rowCard(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Ht | ||
| 290 | 275 | meta: acts(ctx, d), |
| 291 | 276 | }); |
| 292 | 277 | }); |
| 293 | // On a phone a group is its head line, its asks one tap away, so two | |
| 294 | // groups never push the composer past the fold; a wider screen holds the | |
| 295 | // details open and shows every line, as before. | |
| 296 | 278 | return card( |
| 297 | html`<details class="ask-group"> | |
| 298 | <summary>Show all ${r.items.length}</summary> | |
| 299 | <ul class="list"> | |
| 300 | ${lines} | |
| 301 | </ul> | |
| 302 | </details>`, | |
| 279 | html`<ul class="list"> | |
| 280 | ${lines} | |
| 281 | </ul>`, | |
| 303 | 282 | { |
| 304 | 283 | pad: false, |
| 305 | 284 | id: askAnchor(r.id), |
| 306 | extra: "ask-card", | |
| 307 | 285 | head: html`${BULK_KINDS.has(r.kind) |
| 308 | ? html`<label class="ask-pick-hit"><input | |
| 286 | ? html`<label class="check"><input | |
| 309 | 287 | type="checkbox" |
| 310 | class="ask-pick" | |
| 311 | 288 | data-select-all |
| 312 | 289 | aria-label="Select all ${r.items.length}" |
| 313 | 290 | /></label>` |
src/web/style/content.ts −52
| @@ -976,8 +976,6 @@ textarea.editor:focus-visible { outline: none; } | ||
| 976 | 976 | its words, not the column. */ |
| 977 | 977 | #needs-you #asks-bulk select { width: auto; max-width: 100%; } |
| 978 | 978 | #needs-you #asks-bulk .input[name="reason"] { flex: 1 1 12rem; width: auto; min-width: 0; } |
| 979 | /* A row's box sits in a label, so a phone's thumb has 44 px to hit. */ | |
| 980 | .ask-pick-hit { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; } | |
| 981 | 979 | /* A phone shows the top asks and one "N more" that opens in place to all |
| 982 | 980 | of them; a wider screen shows ASKS_SHOWN and its own "N more" past that. |
| 983 | 981 | Only where a details element can be held open: elsewhere both summaries |
| @@ -992,58 +990,8 @@ textarea.editor:focus-visible { outline: none; } | ||
| 992 | 990 | #needs-you #asks-phone-more > summary { display: none; } |
| 993 | 991 | #needs-you #asks-phone-more::details-content { content-visibility: visible; } |
| 994 | 992 | #needs-you #asks-phone-more > .stack { margin-top: 0; } |
| 995 | #needs-you .ask-group > summary { display: none; } | |
| 996 | #needs-you .ask-group::details-content { content-visibility: visible; } | |
| 997 | 993 | } |
| 998 | 994 | } |
| 999 | #needs-you .ask-group > summary { padding: 10px 14px; cursor: pointer; } | |
| 1000 | /* An ask's actions: the decider, then the rest. Wide: in a line, as ever. | |
| 1001 | The kind label is for a phone, where the sentence under the title goes. */ | |
| 1002 | .ask-acts { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 12px; } | |
| 1003 | .ask-more-acts { display: contents; } | |
| 1004 | .ask-more-acts > summary { list-style: none; } | |
| 1005 | .ask-more-acts > summary::-webkit-details-marker { display: none; } | |
| 1006 | .ask-kind { display: none; } | |
| 1007 | .ask-waiting { display: inline-block; margin-left: 8px; padding: 0 6px; border: 1px solid var(--border); border-radius: 999px; color: var(--fg); white-space: nowrap; max-width: 100%; } | |
| 1008 | .row .ask-acts { margin-top: 0; } | |
| 1009 | @supports selector(::details-content) { | |
| 1010 | @media (min-width: 481px) { | |
| 1011 | .ask-more-acts > summary { display: none; } | |
| 1012 | .ask-more-acts::details-content { content-visibility: visible; display: contents; } | |
| 1013 | } | |
| 1014 | } | |
| 1015 | /* A phone: rows run edge to edge (the card's box, border and body padding | |
| 1016 | go; the page's 16 px gutter stays), the title wraps under "kind · repo", | |
| 1017 | the decider sits at the trailing edge and the rest wait behind "More". */ | |
| 1018 | @media (max-width: 480px) { | |
| 1019 | #needs-you .ask-card { | |
| 1020 | margin-inline: -16px; border-inline: 0; border-radius: 0; box-shadow: none; background: none; | |
| 1021 | } | |
| 1022 | .ask-card .card-head { padding: 12px 16px 0; background: none; border: 0; gap: 4px 8px; align-items: baseline; } | |
| 1023 | .ask-card .card-body { padding: 4px 16px 12px; } | |
| 1024 | .ask-card .card-foot { padding: 0 16px 12px; background: none; border: 0; } | |
| 1025 | .ask-card .card-head h2 { order: 2; flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; font-size: 1.05em; } | |
| 1026 | .ask-card .card-head h2 a { white-space: normal; overflow: visible; text-overflow: clip; } | |
| 1027 | .ask-card .ask-meta { order: 1; flex: 1; min-width: 0; overflow-wrap: anywhere; } | |
| 1028 | .ask-card .ask-kind { display: inline; margin-right: 6px; color: var(--fg); } | |
| 1029 | .ask-card .ask-kind::after { content: " ·"; font-weight: 400; } | |
| 1030 | .ask-card .ask-short { display: none; } | |
| 1031 | .ask-card .ask-waiting { order: 3; flex: 0 1 auto; margin-left: 0; min-width: 0; max-width: 100%; white-space: normal; } | |
| 1032 | .ask-card .ask-pick-hit { order: 0; } | |
| 1033 | #needs-you .ask-pick-hit { min-width: 44px; min-height: 44px; } | |
| 1034 | .ask-card .ask-pick { min-width: 24px; min-height: 24px; } | |
| 1035 | .ask-card .ask-group > summary { padding: 10px 16px; min-height: 44px; } | |
| 1036 | .ask-card .ask-group .row { flex-wrap: wrap; padding-inline: 16px; } | |
| 1037 | .ask-card .ask-group .row-main { flex-basis: 100%; } | |
| 1038 | .ask-card .ask-group .row-meta { flex: 1 1 100%; white-space: normal; } | |
| 1039 | .ask-acts { flex-direction: row-reverse; justify-content: space-between; } | |
| 1040 | .ask-acts > .btn { min-height: 44px; min-width: 96px; padding: 0 20px; flex: 0 0 auto; } | |
| 1041 | .ask-more-acts { display: flex; flex-wrap: wrap; gap: 6px; } | |
| 1042 | .ask-more-acts > summary { display: inline-flex; min-height: 44px; padding: 0 16px; } | |
| 1043 | .ask-more-acts[open] { flex: 1 1 100%; order: 2; } | |
| 1044 | .ask-more-acts[open] > summary { display: none; } | |
| 1045 | .ask-more-acts > .btn { min-height: 44px; flex: 1 1 auto; } | |
| 1046 | } | |
| 1047 | 995 | /* Reduced motion: the pulses hold still at a legible level, the scroll-tied |
| 1048 | 996 | fade stays at its resting width, and these transitions land at once. */ |
| 1049 | 997 | @media (prefers-reduced-motion: reduce) { |
test/asks-rank.test.ts +8 −18
| @@ -339,20 +339,17 @@ describe("asks that earn their place", () => { | ||
| 339 | 339 | recordAskLedger(app.db); |
| 340 | 340 | expect((await mcpAsks()).measure.raised).toBe(6); |
| 341 | 341 | }); |
| 342 | test("a phone's ask is a full-width row: kind and repo on one line, the decider apart, the rest behind More", async () => { | |
| 342 | test("an ask is §5's card with §5's buttons and no ask-* class", async () => { | |
| 343 | 343 | const home = await page("/asks"); |
| 344 | 344 | const asks = home.slice(home.indexOf('<section id="needs-you"')); |
| 345 | expect(asks).toContain("ask-card"); | |
| 346 | expect(asks).toContain('<b class="ask-kind">'); | |
| 347 | // No icon column: the row opens with its kind, not a picture. | |
| 348 | expect(asks.slice(0, asks.indexOf("<h2", asks.indexOf("ask-card")))).not.toContain("<svg"); | |
| 349 | expect(asks).toContain('<details class="ask-more-acts">'); | |
| 350 | expect(asks).toMatch(/class="ask-acts">\s*<button[^>]*data-variant="primary"/); | |
| 345 | expect(asks).toMatch(/<div\s+class="card"\s+id="look-/); | |
| 346 | expect(asks).toContain("<b>Ready to ship</b> · "); | |
| 347 | expect(asks).toMatch(/class="bar mt">\s*<button[^>]*data-variant="primary"/); | |
| 348 | expect(asks).not.toMatch(/class="[^"]*\bask-(card|acts|kind|pick|group|more-acts|waiting|meta|short)\b/); | |
| 351 | 349 | const css = await (await fetch(`${servers.url}${home.match(/href="(\/[^"]*\.css[^"]*)"/)![1]}`)).text(); |
| 352 | expect(css).toContain(".ask-acts > .btn { min-height: 44px;"); | |
| 350 | expect(css).not.toMatch(/\.ask-/); | |
| 353 | 351 | // The markup lost the .needs-you class (#4189): no selector may still need it. |
| 354 | 352 | expect(css).not.toMatch(/\.needs-you\b/); |
| 355 | expect(css).toContain("#needs-you .ask-card {\n margin-inline: -16px;"); | |
| 356 | 353 | }); |
| 357 | 354 | |
| 358 | 355 | test("a collapsed row that ranks for security names which of its asks it is", async () => { |
| @@ -362,15 +359,8 @@ describe("asks that earn their place", () => { | ||
| 362 | 359 | const asks = await page("/asks"); |
| 363 | 360 | expect(asks).toContain("2 green changes wait for your look"); |
| 364 | 361 | expect(asks).toContain("<b>security</b>: \u201cWiden the board id\u201d"); |
| 365 | // On a phone the row is its head line, its two asks one tap away; a | |
| 366 | // wider screen holds them open (the lead's call: the fold wins). | |
| 367 | 362 | const group = asks.slice(asks.indexOf("2 green changes wait for your look")); |
| 368 | expect(group).toContain('<details class="ask-group">'); | |
| 369 | expect(group).toContain("<summary>Show all 2</summary>"); | |
| 370 | const css = await (await fetch(`${servers.url}${asks.match(/href="(\/[^"]*\.css[^"]*)"/)![1]}`)).text(); | |
| 371 | const wide = css.slice(css.indexOf("@supports selector(::details-content) {")); | |
| 372 | expect(wide.slice(wide.indexOf("@media (min-width: 481px)"))).toContain( | |
| 373 | "#needs-you .ask-group > summary { display: none; }", | |
| 374 | ); | |
| 363 | expect(group).toContain('<ul class="list">'); | |
| 364 | expect(group).not.toContain("ask-group"); | |
| 375 | 365 | }); |
| 376 | 366 | }); |
test/asks-waiting.test.ts +1 −1
| @@ -180,7 +180,7 @@ describe("asks that wait on a person", () => { | ||
| 180 | 180 | expect(page).toContain("Say what to change"); |
| 181 | 181 | expect(row).not.toContain("/settings/autonomy"); |
| 182 | 182 | expect(page).toContain(">Handed over<"); |
| 183 | expect(page).toMatch(/class="ask-pick"[^>]*value="waiting:[^"]+"[^>]*data-kind="waiting"/); | |
| 183 | expect(page).toMatch(/<label class="check"><input[^>]*name="item"[^>]*value="waiting:[^"]+"[^>]*data-kind="waiting"/); | |
| 184 | 184 | }); |
| 185 | 185 | |
| 186 | 186 | test("the MCP asks tool carries the waiting kind, its why and the recommendation", async () => { |
new above
offline