Public: anyone can read this; the crew can change it.
The studio's Map is the one studio page the words table never opens #248
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/197-the-studio-s-map-is-the-one-studio-page → main 1 commit fork
2 files
changed +90
−7
src/web/pages/map.ts +40 −7
| @@ -54,6 +54,11 @@ import { html, type Html } from "../html.ts"; | ||
| 54 | 54 | import { page } from "../layout.ts"; |
| 55 | 55 | import { avatar, hueOf, icon } from "../ui.ts"; |
| 56 | 56 | import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts"; |
| 57 | import { studioRecord, tellHref } from "../studio/nouns.ts"; | |
| 58 | ||
| 59 | /** Where a card's own link goes: dev's issue page, by default. */ | |
| 60 | const devIssueHref = (base: string, number: number): string => | |
| 61 | `${base}/issues/${number}`; | |
| 57 | 62 | |
| 58 | 63 | // ---- layout ---- |
| 59 | 64 | |
| @@ -583,7 +588,13 @@ function geometry(placed: Layout, minWidth: number): Geometry { | ||
| 583 | 588 | }; |
| 584 | 589 | } |
| 585 | 590 | |
| 586 | function node(base: string, n: Placed, g: Geometry, emphasis: Emphasis): Html { | |
| 591 | function node( | |
| 592 | base: string, | |
| 593 | n: Placed, | |
| 594 | g: Geometry, | |
| 595 | emphasis: Emphasis, | |
| 596 | issueHref: (base: string, number: number) => string, | |
| 597 | ): Html { | |
| 587 | 598 | const word = wordFor(n.issue, emphasis.ready, emphasis.marks); |
| 588 | 599 | const run = emphasis.marks.crew.get(n.issue.id); |
| 589 | 600 | const [first, second] = wrapTitle(n.issue.title); |
| @@ -599,7 +610,7 @@ function node(base: string, n: Placed, g: Geometry, emphasis: Emphasis): Html { | ||
| 599 | 610 | data-state="${word}" |
| 600 | 611 | data-crit="${emphasis.critical.has(n.issue.id) ? "1" : "0"}" |
| 601 | 612 | ${run ? html`data-run="${run}"` : null} |
| 602 | href="${base}/issues/${n.issue.number}" | |
| 613 | href="${issueHref(base, n.issue.number)}" | |
| 603 | 614 | > |
| 604 | 615 | <title>${number} ${n.issue.title} — ${word}</title> |
| 605 | 616 | ${word === "working" |
| @@ -694,8 +705,14 @@ export interface DrawOptions { | ||
| 694 | 705 | readonly minWidth?: number; |
| 695 | 706 | /** The most of the window's height the canvas takes before it pans. */ |
| 696 | 707 | readonly cap?: number; |
| 708 | /** Where a card's own link goes. Defaults to dev's `/issues/:number`. */ | |
| 709 | readonly issueHref?: (base: string, number: number) => string; | |
| 710 | /** What the empty state says, in the reader's own noun for an issue. */ | |
| 711 | readonly emptyHtml?: Html; | |
| 697 | 712 | } |
| 698 | 713 | |
| 714 | const DEV_EMPTY = html`<p>No open issues to map. Open one, then link what it waits for.</p>`; | |
| 715 | ||
| 699 | 716 | export function drawMap( |
| 700 | 717 | repo: Repo, |
| 701 | 718 | graph: Graph, |
| @@ -705,10 +722,10 @@ export function drawMap( | ||
| 705 | 722 | ): Html { |
| 706 | 723 | if (placed.nodes.length === 0) |
| 707 | 724 | return html`<div class="map-empty"> |
| 708 | ${icon("map", 20)} | |
| 709 | <p>No open issues to map. Open one, then link what it waits for.</p> | |
| 725 | ${icon("map", 20)} ${options.emptyHtml ?? DEV_EMPTY} | |
| 710 | 726 | </div>`; |
| 711 | 727 | const base = repoBase(repo); |
| 728 | const issueHref = options.issueHref ?? devIssueHref; | |
| 712 | 729 | const g = geometry(placed, options.minWidth ?? 0); |
| 713 | 730 | const at = new Map(placed.nodes.map((n) => [n.issue.id, n])); |
| 714 | 731 | const ry = g.y(placed.islandFrom) - GY / 2 - RULE / 2; |
| @@ -746,7 +763,7 @@ export function drawMap( | ||
| 746 | 763 | ${graph.edges.map((e) => |
| 747 | 764 | edge(e, at, routed.get(routeKey(e)) ?? [], g, emphasis), |
| 748 | 765 | )} |
| 749 | ${placed.nodes.map((n) => node(base, n, g, emphasis))} | |
| 766 | ${placed.nodes.map((n) => node(base, n, g, emphasis, issueHref))} | |
| 750 | 767 | </svg> |
| 751 | 768 | <div class="map-tools js-only" role="group" aria-label="Zoom"> |
| 752 | 769 | <button |
| @@ -802,6 +819,7 @@ function rankedList( | ||
| 802 | 819 | items: Ranked[], |
| 803 | 820 | say: (r: Ranked) => string, |
| 804 | 821 | empty: string, |
| 822 | issueHref: (base: string, number: number) => string, | |
| 805 | 823 | ): Html { |
| 806 | 824 | const base = repoBase(repo); |
| 807 | 825 | return html`<section class="map-list"> |
| @@ -812,7 +830,9 @@ function rankedList( | ||
| 812 | 830 | ${items.slice(0, 6).map( |
| 813 | 831 | (r) => |
| 814 | 832 | html`<li> |
| 815 | <a href="${base}/issues/${r.issue.number}">${r.issue.title}</a> | |
| 833 | <a href="${issueHref(base, r.issue.number)}" | |
| 834 | >${r.issue.title}</a | |
| 835 | > | |
| 816 | 836 | <span class="muted small">#${r.issue.number} · ${say(r)}</span> |
| 817 | 837 | </li>`, |
| 818 | 838 | )} |
| @@ -1160,11 +1180,20 @@ function autonomyLine(ctx: Ctx, repo: Repo): Html | null { | ||
| 1160 | 1180 | |
| 1161 | 1181 | const LIVE_KINDS = "run.*,issue.*,pull.*"; |
| 1162 | 1182 | |
| 1183 | /** The empty state in the studio's own words, pointing at the app page's | |
| 1184 | * own box for telling the crew (`#tell`, `src/web/studio/app.ts`). */ | |
| 1185 | const studioEmpty = (base: string): Html => | |
| 1186 | html`<p>No tells to map yet. <a href="${base}#tell">Say one</a>, then link what it waits for.</p>`; | |
| 1187 | ||
| 1163 | 1188 | /** GET /:owner/:name/map */ |
| 1164 | 1189 | export function mapPage(ctx: Ctx): Response { |
| 1165 | 1190 | const repo = loadRepo(ctx); |
| 1166 | 1191 | if (!repo) return notFoundPage(ctx); |
| 1167 | 1192 | const db = ctx.app.db; |
| 1193 | // In the studio a card is a tell and its link is `/tells/:number` — the | |
| 1194 | // same rule every other studio surface follows (`studioRecord`). | |
| 1195 | const studio = studioRecord(ctx, repo) !== undefined; | |
| 1196 | const issueHref = studio ? tellHref : devIssueHref; | |
| 1168 | 1197 | const graph = repoGraph(db, repo.id); |
| 1169 | 1198 | const ready = readyNow(graph); |
| 1170 | 1199 | const blocked = blockedOn(graph); |
| @@ -1194,6 +1223,8 @@ export function mapPage(ctx: Ctx): Response { | ||
| 1194 | 1223 | ${drawMap(repo, graph, placed, emphasisFor(ready, critical, marks), { |
| 1195 | 1224 | id: "map-canvas", |
| 1196 | 1225 | cap: 1, |
| 1226 | issueHref, | |
| 1227 | ...(studio ? { emptyHtml: studioEmpty(repoBase(repo)) } : {}), | |
| 1197 | 1228 | })} |
| 1198 | 1229 | ${placed.nodes.length === 0 ? null : countsLine(counts)} |
| 1199 | 1230 | </div> |
| @@ -1209,6 +1240,7 @@ export function mapPage(ctx: Ctx): Response { | ||
| 1209 | 1240 | ? "holds nothing up" |
| 1210 | 1241 | : `finishing it frees ${r.holdsUp}`, |
| 1211 | 1242 | "Nothing is ready: everything open waits for something.", |
| 1243 | issueHref, | |
| 1212 | 1244 | )} |
| 1213 | 1245 | ${rankedList( |
| 1214 | 1246 | repo, |
| @@ -1216,6 +1248,7 @@ export function mapPage(ctx: Ctx): Response { | ||
| 1216 | 1248 | blocked, |
| 1217 | 1249 | (r) => `holds up ${r.holdsUp}`, |
| 1218 | 1250 | "Nothing open holds anything up.", |
| 1251 | issueHref, | |
| 1219 | 1252 | )} |
| 1220 | 1253 | ${critical.length > 1 |
| 1221 | 1254 | ? html`<section class="map-list"> |
| @@ -1224,7 +1257,7 @@ export function mapPage(ctx: Ctx): Response { | ||
| 1224 | 1257 | ${critical.map( |
| 1225 | 1258 | (i) => |
| 1226 | 1259 | html`<li> |
| 1227 | <a href="${repoBase(repo)}/issues/${i.number}" | |
| 1260 | <a href="${issueHref(repoBase(repo), i.number)}" | |
| 1228 | 1261 | >${i.title}</a |
| 1229 | 1262 | > |
| 1230 | 1263 | </li>`, |
test/studio-words.test.ts +50
| @@ -449,6 +449,34 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 449 | 449 | claimRun(app.db, working.id); |
| 450 | 450 | workingRunId = working.id; |
| 451 | 451 | |
| 452 | // A second app with nothing told yet — the map's own empty state, what | |
| 453 | // a new app sees, which the crowded "vocab" repo above can never show. | |
| 454 | const blankRepo = await createRepo(app, a.value, { | |
| 455 | name: "blank", | |
| 456 | visibility: "private", | |
| 457 | description: "nothing told yet", | |
| 458 | }); | |
| 459 | if (!blankRepo.ok) throw new Error(String(blankRepo.error)); | |
| 460 | const blankSeeded = await commitToBranch(app, a.value, blankRepo.value, { | |
| 461 | message: "Start", | |
| 462 | files: [ | |
| 463 | { | |
| 464 | path: "package.json", | |
| 465 | content: '{"name":"blank","scripts":{"start":"bun run server.ts"}}\n', | |
| 466 | }, | |
| 467 | { | |
| 468 | path: "server.ts", | |
| 469 | content: | |
| 470 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 471 | }, | |
| 472 | ], | |
| 473 | }); | |
| 474 | if (!blankSeeded.ok) throw new Error(JSON.stringify(blankSeeded.error)); | |
| 475 | const blankEnabled = await enableApp(app, a.value, blankRepo.value, { | |
| 476 | slug: "blank", | |
| 477 | }); | |
| 478 | if (!blankEnabled.ok) throw new Error(String(blankEnabled.error)); | |
| 479 | ||
| 452 | 480 | const repoPath = "/alice/vocab"; |
| 453 | 481 | // Every studio page, one row each. A new studio page goes here — the |
| 454 | 482 | // path it answers at, the word it speaks, and dev's own words at the |
| @@ -502,6 +530,28 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 502 | 530 | path: `${repoPath}/pulls/${mergedPullNumber}`, |
| 503 | 531 | dev: ["diff-table"], |
| 504 | 532 | }, |
| 533 | { | |
| 534 | // #197: the map used to link every card to dev's `/issues/:number` | |
| 535 | // regardless of mode — the one studio page mode-blind about it. | |
| 536 | word: "map", | |
| 537 | path: `${repoPath}/map`, | |
| 538 | shows: [ | |
| 539 | tellTitle, | |
| 540 | untouchedTitle, | |
| 541 | `href="${repoPath}/tells/${tellNumber}"`, | |
| 542 | `href="${repoPath}/tells/${untouchedNumber}"`, | |
| 543 | ], | |
| 544 | hides: [ | |
| 545 | `href="${repoPath}/issues/${tellNumber}"`, | |
| 546 | `href="${repoPath}/issues/${untouchedNumber}"`, | |
| 547 | ], | |
| 548 | }, | |
| 549 | { | |
| 550 | // The empty case: what a new app's map says before anything is told. | |
| 551 | word: "map (empty)", | |
| 552 | path: "/alice/blank/map", | |
| 553 | shows: ["No tells to map yet."], | |
| 554 | }, | |
| 505 | 555 | // A word from the app's own description, not its name — the app's own |
| 506 | 556 | // row coming back for it proves the search reaches the description, |
| 507 | 557 | // not just a name match. |
new above
offline