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

Merged opened this

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

Merged

scout/forge:scout/4292-the-map-s-empty-state-and-list-sections → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/4292-the-map-s-empty-state-and-list-sections was merged into main as a0c9cd7.

Review

Sign in to review this change.
5 files changed +105 −69
docs/DESIGN.md +1 −1
@@ -401,7 +401,7 @@ gone, so this list only shrinks.
401401 - **`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`
402402 - **`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`
403403 - **`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`
405405 - **`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`
406406 ---
407407
src/web/pages/map.ts +73 −58
@@ -57,7 +57,7 @@ import {
5757 import { liveRegionsJs } from "../client.ts";
5858 import { html, type Html } from "../html.ts";
5959 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";
6161 import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts";
6262 import {
6363 parkedTwice,
@@ -819,10 +819,20 @@ export interface DrawOptions {
819819 /** Where a card's own link goes. Defaults to dev's `/issues/:number`. */
820820 readonly issueHref?: (base: string, number: number) => string;
821821 /** What the empty state says, in the reader's own noun for an issue. */
822 readonly emptyHtml?: Html;
822 readonly empty?: MapEmpty;
823823 }
824824
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 }
826836
827837 export function drawMap(
828838 repo: Repo,
@@ -832,9 +842,7 @@ export function drawMap(
832842 options: DrawOptions,
833843 ): Html {
834844 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) });
838846 const base = repoBase(repo);
839847 const issueHref = options.issueHref ?? devIssueHref;
840848 const g = geometry(placed, options.minWidth ?? 0);
@@ -949,21 +957,23 @@ function rankedList(
949957 issueHref: (base: string, number: number) => string,
950958 ): Html {
951959 const base = repoBase(repo);
952 return html`<section class="map-list">
960 return html`<section class="stack-tight">
953961 <h2>${title}</h2>
954962 ${items.length === 0
955963 ? 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 )}
967977 </section>`;
968978 }
969979
@@ -1052,44 +1062,48 @@ function pillarList(repo: Repo, rows: readonly PillarRow[]): Html {
10521062 r.open === 0
10531063 ? "nothing open"
10541064 : `${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">
10561066 <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(
10631074 (i) =>
10641075 html`<a href="${base}/issues/${i.number}" title="${i.title}"
10651076 >#${i.number}</a
10661077 >${" "}`,
10671078 )}${(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 )}
10931107 </section>`;
10941108 }
10951109
@@ -1174,10 +1188,7 @@ export const mapCss = /* css */ `
11741188 .map-list ol li::marker { color: var(--faint); font-variant-numeric: tabular-nums; }
11751189 .map-list li span { display: block; }
11761190 .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; }
11811192 .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; }
11821193
11831194 /* ---- lanes: §5 list rows that fold ---- */
@@ -1185,6 +1196,7 @@ export const mapCss = /* css */ `
11851196 #lanes .row-meta .map-counts { margin: 0; }
11861197 #lanes li[data-autonomy="autonomous"] .row-title .badge { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); }
11871198 #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; }
11881200 #lanes details > .map-canvas, #lanes details > .map-empty { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
11891201 @media (prefers-reduced-motion: reduce) {
11901202 .map-card, .map-title { transition: none; }
@@ -1355,8 +1367,11 @@ const LIVE_KINDS = "run.queued,run.started,run.finished,issue.*,pull.*";
13551367
13561368 /** The empty state in the studio's own words, pointing at the app page's
13571369 * 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 });
13601375
13611376 /** GET /:owner/:name/map */
13621377 export async function mapPage(ctx: Ctx): Promise<Response> {
@@ -1398,11 +1413,11 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
13981413 id: "map-canvas",
13991414 cap: 1,
14001415 issueHref: tellHref,
1401 ...(studio ? { emptyHtml: studioEmpty(repoBase(repo)) } : {}),
1416 ...(studio ? { empty: studioEmpty(repoBase(repo)) } : {}),
14021417 })}
14031418 ${placed.nodes.length === 0 ? null : countsLine(counts)}
14041419 </div>
1405 <aside>
1420 <aside class="stack">
14061421 ${pillars === undefined
14071422 ? null
14081423 : pillarList(
test/map.test.ts +19
@@ -381,6 +381,10 @@ describe("the map, per app and across the forge", () => {
381381 // accent on the canvas alone, and spend and flow live on their boards.
382382 expect(page).toContain("Ready now");
383383 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");
384388 expect(page).not.toContain("Critical path");
385389 expect(page).not.toContain("Two weeks");
386390 expect(page).not.toContain("map-autonomy");
@@ -495,6 +499,21 @@ describe("the map, per app and across the forge", () => {
495499 expect(bobs).not.toContain("Extract renderer");
496500 });
497501
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
498517 test("nothing to draw anywhere is one quiet sentence; a stranger is sent to sign in", async () => {
499518 const page = await (
500519 await fetch(`${base}/map`, { headers: { cookie: await signIn("carol") } })
test/pillars.test.ts +11 −9
@@ -400,15 +400,17 @@ describe("pillars", () => {
400400 ),
401401 );
402402 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/);
412414 expect(page).toContain(`href="/alice/engine/issues/${loose.number}"`);
413415 // The pillar's label narrows the one list.
414416 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", () => {
582582 // The empty case: what a new app's map says before anything is told.
583583 word: "map (empty)",
584584 path: "/alice/blank/map",
585 shows: ["No issues to map yet."],
585 shows: ["No issues to map yet"],
586586 },
587587 // A word from the app's own description, not its name — the app's own
588588 // row coming back for it proves the search reaches the description,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs