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

Merged opened this

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

Merged

scout/forge:scout/197-the-studio-s-map-is-the-one-studio-page → main 1 commit fork

  • Merged. scout/197-the-studio-s-map-is-the-one-studio-page was merged into main as af8eafb.

Review

Sign in to review this change.
2 files changed +90 −7
src/web/pages/map.ts +40 −7
@@ -54,6 +54,11 @@ import { html, type Html } from "../html.ts";
5454 import { page } from "../layout.ts";
5555 import { avatar, hueOf, icon } from "../ui.ts";
5656 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}`;
5762
5863 // ---- layout ----
5964
@@ -583,7 +588,13 @@ function geometry(placed: Layout, minWidth: number): Geometry {
583588 };
584589 }
585590
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 {
587598 const word = wordFor(n.issue, emphasis.ready, emphasis.marks);
588599 const run = emphasis.marks.crew.get(n.issue.id);
589600 const [first, second] = wrapTitle(n.issue.title);
@@ -599,7 +610,7 @@ function node(base: string, n: Placed, g: Geometry, emphasis: Emphasis): Html {
599610 data-state="${word}"
600611 data-crit="${emphasis.critical.has(n.issue.id) ? "1" : "0"}"
601612 ${run ? html`data-run="${run}"` : null}
602 href="${base}/issues/${n.issue.number}"
613 href="${issueHref(base, n.issue.number)}"
603614 >
604615 <title>${number} ${n.issue.title} — ${word}</title>
605616 ${word === "working"
@@ -694,8 +705,14 @@ export interface DrawOptions {
694705 readonly minWidth?: number;
695706 /** The most of the window's height the canvas takes before it pans. */
696707 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;
697712 }
698713
714 const DEV_EMPTY = html`<p>No open issues to map. Open one, then link what it waits for.</p>`;
715
699716 export function drawMap(
700717 repo: Repo,
701718 graph: Graph,
@@ -705,10 +722,10 @@ export function drawMap(
705722 ): Html {
706723 if (placed.nodes.length === 0)
707724 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}
710726 </div>`;
711727 const base = repoBase(repo);
728 const issueHref = options.issueHref ?? devIssueHref;
712729 const g = geometry(placed, options.minWidth ?? 0);
713730 const at = new Map(placed.nodes.map((n) => [n.issue.id, n]));
714731 const ry = g.y(placed.islandFrom) - GY / 2 - RULE / 2;
@@ -746,7 +763,7 @@ export function drawMap(
746763 ${graph.edges.map((e) =>
747764 edge(e, at, routed.get(routeKey(e)) ?? [], g, emphasis),
748765 )}
749 ${placed.nodes.map((n) => node(base, n, g, emphasis))}
766 ${placed.nodes.map((n) => node(base, n, g, emphasis, issueHref))}
750767 </svg>
751768 <div class="map-tools js-only" role="group" aria-label="Zoom">
752769 <button
@@ -802,6 +819,7 @@ function rankedList(
802819 items: Ranked[],
803820 say: (r: Ranked) => string,
804821 empty: string,
822 issueHref: (base: string, number: number) => string,
805823 ): Html {
806824 const base = repoBase(repo);
807825 return html`<section class="map-list">
@@ -812,7 +830,9 @@ function rankedList(
812830 ${items.slice(0, 6).map(
813831 (r) =>
814832 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 >
816836 <span class="muted small">#${r.issue.number} · ${say(r)}</span>
817837 </li>`,
818838 )}
@@ -1160,11 +1180,20 @@ function autonomyLine(ctx: Ctx, repo: Repo): Html | null {
11601180
11611181 const LIVE_KINDS = "run.*,issue.*,pull.*";
11621182
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
11631188 /** GET /:owner/:name/map */
11641189 export function mapPage(ctx: Ctx): Response {
11651190 const repo = loadRepo(ctx);
11661191 if (!repo) return notFoundPage(ctx);
11671192 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;
11681197 const graph = repoGraph(db, repo.id);
11691198 const ready = readyNow(graph);
11701199 const blocked = blockedOn(graph);
@@ -1194,6 +1223,8 @@ export function mapPage(ctx: Ctx): Response {
11941223 ${drawMap(repo, graph, placed, emphasisFor(ready, critical, marks), {
11951224 id: "map-canvas",
11961225 cap: 1,
1226 issueHref,
1227 ...(studio ? { emptyHtml: studioEmpty(repoBase(repo)) } : {}),
11971228 })}
11981229 ${placed.nodes.length === 0 ? null : countsLine(counts)}
11991230 </div>
@@ -1209,6 +1240,7 @@ export function mapPage(ctx: Ctx): Response {
12091240 ? "holds nothing up"
12101241 : `finishing it frees ${r.holdsUp}`,
12111242 "Nothing is ready: everything open waits for something.",
1243 issueHref,
12121244 )}
12131245 ${rankedList(
12141246 repo,
@@ -1216,6 +1248,7 @@ export function mapPage(ctx: Ctx): Response {
12161248 blocked,
12171249 (r) => `holds up ${r.holdsUp}`,
12181250 "Nothing open holds anything up.",
1251 issueHref,
12191252 )}
12201253 ${critical.length > 1
12211254 ? html`<section class="map-list">
@@ -1224,7 +1257,7 @@ export function mapPage(ctx: Ctx): Response {
12241257 ${critical.map(
12251258 (i) =>
12261259 html`<li>
1227 <a href="${repoBase(repo)}/issues/${i.number}"
1260 <a href="${issueHref(repoBase(repo), i.number)}"
12281261 >${i.title}</a
12291262 >
12301263 </li>`,
test/studio-words.test.ts +50
@@ -449,6 +449,34 @@ describe("dev's vocabulary never reaches a studio page", () => {
449449 claimRun(app.db, working.id);
450450 workingRunId = working.id;
451451
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
452480 const repoPath = "/alice/vocab";
453481 // Every studio page, one row each. A new studio page goes here — the
454482 // 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", () => {
502530 path: `${repoPath}/pulls/${mergedPullNumber}`,
503531 dev: ["diff-table"],
504532 },
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 },
505555 // A word from the app's own description, not its name — the app's own
506556 // row coming back for it proves the search reaches the description,
507557 // not just a name match.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs