Public: anyone can read this; the crew can change it.
The map's empty state and list sections draw §5's empty and stack, and .map-empty, .map-list leave the markup #4336
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4292-the-map-s-empty-state-and-list-sections → main 4 commits fork 1 thread · 1 unresolved
5 files
changed +105
−69
docs/DESIGN.md +1 −1
| @@ -401,7 +401,7 @@ gone, so this list only shrinks. | ||
| 401 | 401 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**): `.ln-forge` `.ln-pulse` `.ln-say` `.ln-tool` `.ln-you` `.log-tail-row` `.pinned-failures` `.pinned-slot` `.run-jump` `.run-live` `.run-steer` `.txt` `.who` |
| 402 | 402 | - **`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` `.live-toggle` `.needs-you` |
| 403 | 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 |
| 404 | - **`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-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` | |
| 404 | - **`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-list` `.map-main` `.map-meta` `.map-node` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` | |
| 405 | 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` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.home-tabs` |
| 406 | 406 | --- |
| 407 | 407 |
src/web/pages/map.ts +73 −58
| @@ -57,7 +57,7 @@ import { | ||
| 57 | 57 | import { liveRegionsJs } from "../client.ts"; |
| 58 | 58 | import { html, type Html } from "../html.ts"; |
| 59 | 59 | import { page } from "../layout.ts"; |
| 60 | import { avatar, badge, icon, listCard, row } from "../ui.ts"; | |
| 60 | import { avatar, badge, btn, card, empty, icon, listCard, row } from "../ui.ts"; | |
| 61 | 61 | import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts"; |
| 62 | 62 | import { |
| 63 | 63 | parkedTwice, |
| @@ -819,10 +819,20 @@ export interface DrawOptions { | ||
| 819 | 819 | /** Where a card's own link goes. Defaults to dev's `/issues/:number`. */ |
| 820 | 820 | readonly issueHref?: (base: string, number: number) => string; |
| 821 | 821 | /** What the empty state says, in the reader's own noun for an issue. */ |
| 822 | readonly emptyHtml?: Html; | |
| 822 | readonly empty?: MapEmpty; | |
| 823 | 823 | } |
| 824 | 824 | |
| 825 | const DEV_EMPTY = html`<p>No open issues to map. Open one, then link what it waits for.</p>`; | |
| 825 | const DEV_EMPTY: MapEmpty = { | |
| 826 | title: "No open issues to map", | |
| 827 | note: "Open one, then link what it waits for.", | |
| 828 | }; | |
| 829 | ||
| 830 | /** What the map says when there is nothing on it: §5's empty state. */ | |
| 831 | export interface MapEmpty { | |
| 832 | readonly title: string; | |
| 833 | readonly note: string; | |
| 834 | readonly action?: Html; | |
| 835 | } | |
| 826 | 836 | |
| 827 | 837 | export function drawMap( |
| 828 | 838 | repo: Repo, |
| @@ -832,9 +842,7 @@ export function drawMap( | ||
| 832 | 842 | options: DrawOptions, |
| 833 | 843 | ): Html { |
| 834 | 844 | if (placed.nodes.length === 0) |
| 835 | return html`<div class="map-empty"> | |
| 836 | ${icon("map", 20)} ${options.emptyHtml ?? DEV_EMPTY} | |
| 837 | </div>`; | |
| 845 | return empty({ icon: "map", ...(options.empty ?? DEV_EMPTY) }); | |
| 838 | 846 | const base = repoBase(repo); |
| 839 | 847 | const issueHref = options.issueHref ?? devIssueHref; |
| 840 | 848 | const g = geometry(placed, options.minWidth ?? 0); |
| @@ -949,21 +957,23 @@ function rankedList( | ||
| 949 | 957 | issueHref: (base: string, number: number) => string, |
| 950 | 958 | ): Html { |
| 951 | 959 | const base = repoBase(repo); |
| 952 | return html`<section class="map-list"> | |
| 960 | return html`<section class="stack-tight"> | |
| 953 | 961 | <h2>${title}</h2> |
| 954 | 962 | ${items.length === 0 |
| 955 | 963 | ? html`<p class="muted small">${empty}</p>` |
| 956 | : html`<ol> | |
| 957 | ${items.slice(0, 6).map( | |
| 958 | (r) => | |
| 959 | html`<li> | |
| 960 | <a href="${issueHref(base, r.issue.number)}" | |
| 961 | >${r.issue.title}</a | |
| 962 | > | |
| 963 | <span class="muted small">#${r.issue.number} · ${say(r)}</span> | |
| 964 | </li>`, | |
| 965 | )} | |
| 966 | </ol>`} | |
| 964 | : card( | |
| 965 | html`<ul class="list setting-list"> | |
| 966 | ${items.slice(0, 6).map((r) => | |
| 967 | row({ | |
| 968 | href: issueHref(base, r.issue.number), | |
| 969 | title: r.issue.title, | |
| 970 | titleAttr: r.issue.title, | |
| 971 | meta: html`#${r.issue.number} · ${say(r)}`, | |
| 972 | }), | |
| 973 | )} | |
| 974 | </ul>`, | |
| 975 | { pad: false }, | |
| 976 | )} | |
| 967 | 977 | </section>`; |
| 968 | 978 | } |
| 969 | 979 | |
| @@ -1052,44 +1062,48 @@ function pillarList(repo: Repo, rows: readonly PillarRow[]): Html { | ||
| 1052 | 1062 | r.open === 0 |
| 1053 | 1063 | ? "nothing open" |
| 1054 | 1064 | : `${r.open} open · ${r.ready} ready · ${r.blocked} blocked`; |
| 1055 | return html`<section class="map-list" id="map-pillars"> | |
| 1065 | return html`<section class="stack-tight" id="map-pillars"> | |
| 1056 | 1066 | <h2>By pillar</h2> |
| 1057 | <ul class="list"> | |
| 1058 | ${rows.map((r) => | |
| 1059 | r.name === null | |
| 1060 | ? row({ | |
| 1061 | title: NO_PILLAR, | |
| 1062 | meta: html`${(r.issues ?? []).slice(0, 8).map( | |
| 1067 | ${card( | |
| 1068 | html`<ul class="list setting-list"> | |
| 1069 | ${rows.map((r) => | |
| 1070 | r.name === null | |
| 1071 | ? row({ | |
| 1072 | title: NO_PILLAR, | |
| 1073 | meta: html`${(r.issues ?? []).slice(0, 8).map( | |
| 1063 | 1074 | (i) => |
| 1064 | 1075 | html`<a href="${base}/issues/${i.number}" title="${i.title}" |
| 1065 | 1076 | >#${i.number}</a |
| 1066 | 1077 | >${" "}`, |
| 1067 | 1078 | )}${(r.issues ?? []).length > 8 ? "…" : ""}`, |
| 1068 | trail: badge(counts(r)), | |
| 1069 | data: { pillar: "" }, | |
| 1070 | }) | |
| 1071 | : row({ | |
| 1072 | title: html`<a | |
| 1073 | href="${r.umbrella === undefined | |
| 1074 | ? pillarHref(base, r.name) | |
| 1075 | : milestoneHref(base, r.umbrella)}" | |
| 1076 | >${r.name}</a | |
| 1077 | >`, | |
| 1078 | meta: html`<span title="Median and slowest hours from an issue opening to its change live in production, over ${PILLAR_DAYS} days" | |
| 1079 | >${r.merged ?? 0} merged · ask to live | |
| 1080 | ${r.askToLive === undefined | |
| 1081 | ? "—" | |
| 1082 | : html`${hours(r.askToLive.medianHours)} median, slowest | |
| 1083 | <a href="${base}/issues/${r.askToLive.slowest.number}" | |
| 1084 | >#${r.askToLive.slowest.number}</a | |
| 1085 | > | |
| 1086 | ${hours(r.askToLive.slowest.hours)}`}</span | |
| 1087 | >`, | |
| 1088 | trail: badge(counts(r)), | |
| 1089 | data: { pillar: r.name }, | |
| 1090 | }), | |
| 1091 | )} | |
| 1092 | </ul> | |
| 1079 | trail: badge(counts(r)), | |
| 1080 | data: { pillar: "" }, | |
| 1081 | }) | |
| 1082 | : row({ | |
| 1083 | title: html`<a | |
| 1084 | class="break" | |
| 1085 | href="${r.umbrella === undefined | |
| 1086 | ? pillarHref(base, r.name) | |
| 1087 | : milestoneHref(base, r.umbrella)}" | |
| 1088 | >${r.name}</a | |
| 1089 | >`, | |
| 1090 | meta: html`<span class="map-ask" title="Median and slowest hours from an issue opening to its change live in production, over ${PILLAR_DAYS} days" | |
| 1091 | >${r.merged ?? 0} merged · ask to live | |
| 1092 | ${r.askToLive === undefined | |
| 1093 | ? "—" | |
| 1094 | : html`${hours(r.askToLive.medianHours)} median, slowest | |
| 1095 | <a href="${base}/issues/${r.askToLive.slowest.number}" | |
| 1096 | >#${r.askToLive.slowest.number}</a | |
| 1097 | > | |
| 1098 | ${hours(r.askToLive.slowest.hours)}`}</span | |
| 1099 | >`, | |
| 1100 | trail: badge(counts(r)), | |
| 1101 | data: { pillar: r.name }, | |
| 1102 | }), | |
| 1103 | )} | |
| 1104 | </ul>`, | |
| 1105 | { pad: false }, | |
| 1106 | )} | |
| 1093 | 1107 | </section>`; |
| 1094 | 1108 | } |
| 1095 | 1109 | |
| @@ -1174,10 +1188,7 @@ export const mapCss = /* css */ ` | ||
| 1174 | 1188 | .map-list ol li::marker { color: var(--faint); font-variant-numeric: tabular-nums; } |
| 1175 | 1189 | .map-list li span { display: block; } |
| 1176 | 1190 | .map-list + .map-list { margin-top: 18px; } |
| 1177 | .map-ask { display: block; } | |
| 1178 | .map-list .list span { display: inline; } | |
| 1179 | .map-list .list .row-main, .map-list .list .row-trail { display: flex; } | |
| 1180 | .map-pillars ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 14px; } | |
| 1191 | .map-ask { display: inline; } | |
| 1181 | 1192 | .map-empty { display: grid; justify-items: center; gap: 8px; padding: 40px 16px; border: 1px dashed var(--border); border-radius: var(--radius); color: var(--muted-fg); text-align: center; } |
| 1182 | 1193 | |
| 1183 | 1194 | /* ---- lanes: §5 list rows that fold ---- */ |
| @@ -1185,6 +1196,7 @@ export const mapCss = /* css */ ` | ||
| 1185 | 1196 | #lanes .row-meta .map-counts { margin: 0; } |
| 1186 | 1197 | #lanes li[data-autonomy="autonomous"] .row-title .badge { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); } |
| 1187 | 1198 | #lanes li[data-idle] .row-title { color: var(--muted-fg); } |
| 1199 | #lanes details > .card.empty { border: 0; border-top: 1px solid var(--border); border-radius: 0; } | |
| 1188 | 1200 | #lanes details > .map-canvas, #lanes details > .map-empty { border: 0; border-top: 1px solid var(--border); border-radius: 0; } |
| 1189 | 1201 | @media (prefers-reduced-motion: reduce) { |
| 1190 | 1202 | .map-card, .map-title { transition: none; } |
| @@ -1355,8 +1367,11 @@ const LIVE_KINDS = "run.queued,run.started,run.finished,issue.*,pull.*"; | ||
| 1355 | 1367 | |
| 1356 | 1368 | /** The empty state in the studio's own words, pointing at the app page's |
| 1357 | 1369 | * own box for telling the crew (`#tell`, `src/web/studio/app.ts`). */ |
| 1358 | const studioEmpty = (base: string): Html => | |
| 1359 | html`<p>No issues to map yet. <a href="${base}#tell">Say one</a>, then link what it waits for.</p>`; | |
| 1370 | const studioEmpty = (base: string): MapEmpty => ({ | |
| 1371 | title: "No issues to map yet", | |
| 1372 | note: "Say one, then link what it waits for.", | |
| 1373 | action: btn("Say one", { href: `${base}#tell`, variant: "primary" }), | |
| 1374 | }); | |
| 1360 | 1375 | |
| 1361 | 1376 | /** GET /:owner/:name/map */ |
| 1362 | 1377 | export async function mapPage(ctx: Ctx): Promise<Response> { |
| @@ -1398,11 +1413,11 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1398 | 1413 | id: "map-canvas", |
| 1399 | 1414 | cap: 1, |
| 1400 | 1415 | issueHref: tellHref, |
| 1401 | ...(studio ? { emptyHtml: studioEmpty(repoBase(repo)) } : {}), | |
| 1416 | ...(studio ? { empty: studioEmpty(repoBase(repo)) } : {}), | |
| 1402 | 1417 | })} |
| 1403 | 1418 | ${placed.nodes.length === 0 ? null : countsLine(counts)} |
| 1404 | 1419 | </div> |
| 1405 | <aside> | |
| 1420 | <aside class="stack"> | |
| 1406 | 1421 | ${pillars === undefined |
| 1407 | 1422 | ? null |
| 1408 | 1423 | : pillarList( |
test/map.test.ts +19
| @@ -381,6 +381,10 @@ describe("the map, per app and across the forge", () => { | ||
| 381 | 381 | // accent on the canvas alone, and spend and flow live on their boards. |
| 382 | 382 | expect(page).toContain("Ready now"); |
| 383 | 383 | expect(page).toContain("Blocked on"); |
| 384 | expect(page).not.toMatch(/class="[^"]*\bmap-list\b/); | |
| 385 | expect(page).not.toMatch(/class="[^"]*\bmap-empty\b/); | |
| 386 | expect(page).toContain('class="row-title"'); | |
| 387 | expect(page).toContain("finishing it frees"); | |
| 384 | 388 | expect(page).not.toContain("Critical path"); |
| 385 | 389 | expect(page).not.toContain("Two weeks"); |
| 386 | 390 | expect(page).not.toContain("map-autonomy"); |
| @@ -495,6 +499,21 @@ describe("the map, per app and across the forge", () => { | ||
| 495 | 499 | expect(bobs).not.toContain("Extract renderer"); |
| 496 | 500 | }); |
| 497 | 501 | |
| 502 | test("an empty map is §5's empty state, with the words and the way on", async () => { | |
| 503 | const { drawMap, layout } = await import("../src/web/pages/map.ts"); | |
| 504 | const graph = { issues: [], edges: [] } as unknown as Graph; | |
| 505 | const out = String( | |
| 506 | drawMap({ ownerName: "a", name: "b" } as Repo, graph, layout(graph), {} as never, { | |
| 507 | id: "x", | |
| 508 | empty: { title: "Nothing yet", note: "Say one." }, | |
| 509 | }), | |
| 510 | ); | |
| 511 | expect(out).toContain('class="card empty"'); | |
| 512 | expect(out).toContain("Nothing yet"); | |
| 513 | expect(out).toContain("Say one."); | |
| 514 | expect(out).not.toContain("map-empty"); | |
| 515 | }); | |
| 516 | ||
| 498 | 517 | test("nothing to draw anywhere is one quiet sentence; a stranger is sent to sign in", async () => { |
| 499 | 518 | const page = await ( |
| 500 | 519 | await fetch(`${base}/map`, { headers: { cookie: await signIn("carol") } }) |
test/pillars.test.ts +11 −9
| @@ -400,15 +400,17 @@ describe("pillars", () => { | ||
| 400 | 400 | ), |
| 401 | 401 | ); |
| 402 | 402 | expect(page).toContain("3 open · 2 ready · 1 blocked"); |
| 403 | // §5's row and badge draw it; the page-local class is gone, the id stays. | |
| 404 | expect(page).not.toMatch(/class="[^"]*map-pillars/); | |
| 405 | expect(page).toMatch( | |
| 406 | /class="row-title"\s*>no pillar<\/span/, | |
| 407 | ); | |
| 408 | expect(page).toMatch( | |
| 409 | /class="badge"\s*>3 open · 2 ready · 1 blocked<\/span/, | |
| 410 | ); | |
| 411 | expect(page).toMatch(/data-pillar="P0 Trust"/); | |
| 403 | expect(page).toMatch(/class="row-title"\s*>no pillar<\/span/); | |
| 404 | expect(page).toMatch(/class="badge"\s*>3 open · 2 ready · 1 blocked<\/span/); | |
| 405 | // §5's rows draw the section; the old names are gone (#4292). | |
| 406 | expect(page).not.toMatch(/class="[^"]*\bmap-list\b/); | |
| 407 | expect(page).not.toMatch(/class="[^"]*\bmap-empty\b/); | |
| 408 | expect(page).toMatch(/id="map-pillars"[^>]*>\s*<h2>By pillar<\/h2>/); | |
| 409 | expect(page).toContain('data-pillar="P0 Trust"'); | |
| 410 | // No rule restyles the card's rows: the list keeps §5's look, and the | |
| 411 | // ask-to-live line stays on the row's one meta line. | |
| 412 | expect(page).not.toMatch(/\.map-pillars\s+ul\b/); | |
| 413 | expect(page).toMatch(/\.map-ask\s*\{\s*display:\s*inline/); | |
| 412 | 414 | expect(page).toContain(`href="/alice/engine/issues/${loose.number}"`); |
| 413 | 415 | // The pillar's label narrows the one list. |
| 414 | 416 | const list = await get("/alice/engine/issues?label=P0%20Trust"); |
test/studio-words.test.ts +1 −1
| @@ -582,7 +582,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 582 | 582 | // The empty case: what a new app's map says before anything is told. |
| 583 | 583 | word: "map (empty)", |
| 584 | 584 | path: "/alice/blank/map", |
| 585 | shows: ["No issues to map yet."], | |
| 585 | shows: ["No issues to map yet"], | |
| 586 | 586 | }, |
| 587 | 587 | // A word from the app's own description, not its name — the app's own |
| 588 | 588 | // row coming back for it proves the search reaches the description, |
new above
offline