PAP-10242 Phase G: implement Team Catalog UI

Add the Team Catalog browse/detail/install experience built on the Phase E
teams-catalog API and the Phase F design spec.

- ui/src/api/teamCatalog.ts: typed client for /teams/catalog list/detail/files
  and the company-scoped preview/install endpoints.
- ui/src/pages/TeamCatalog.tsx: /teams page — toolbar filters (kind/category/
  risk/search, URL-synced), grouped browse list, detail pane (summary grid,
  agent hierarchy, projects, required skills, env inputs, external sources,
  file inventory), and a conditional multi-step install wizard (target manager,
  source policy, prerequisite skills, categorized preview, apply progress,
  success). Surfaces unsafe-source warnings, blocked-apply states, invalid-
  manifest CTA, and API errors. Sidebar "Teams" entry + route registration.
- Derives the design's richer affordances (required steps, source warnings,
  skill plan) client-side and degrades gracefully against the shipped strict
  API schema (design section 7).
- ui/src/pages/TeamCatalog.test.tsx: renders the real components and covers the
  main install preview path (detail render, preview fetch + categorized plan +
  install submit) and target-manager gating.
- scripts/screenshot-pap-10242.mjs: Playwright capture helper for QA (needs a
  valid board session on the running instance).

Verified: ui typecheck (tsc -b), vite build, and vitest (3/3) all pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 03:00:04 +00:00
1 parent 2b5feda461
commit e0ca97e31c
7 files changed
+2413

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
import path from "node:path";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const playwrightPkgRoot = path.join(
repoRoot,
"node_modules/.pnpm/playwright@1.58.2/node_modules/playwright",
);
const { chromium } = await import(path.join(playwrightPkgRoot, "index.mjs"));
const HOST = process.env.PAP_HOST ?? "100.123.243.20";
const BASE = `http://${HOST}:3100`;
const outDir = path.join(repoRoot, "screenshots/PAP-10242");
await fs.mkdir(outDir, { recursive: true });
// Reuse the PAP-9704 local board session token, re-hosted onto the reachable
// tailscale interface with a fresh client-side expiry (the server validates the
// token value, not the cookie expiry).
const authState = JSON.parse(
await fs.readFile(path.join(repoRoot, "screenshots/PAP-9704-local-auth-state.json"), "utf8"),
);
const sessionCookie = authState.cookies.find((c) => c.name.endsWith("session_token"));
const browser = await chromium.launch({
executablePath: "/srv/paperclip/home/.cache/ms-playwright/chromium-1223/chrome-linux/chrome",
args: ["--no-sandbox"],
});
const results = [];
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
if (sessionCookie) {
await context.addCookies([
{
name: sessionCookie.name,
value: sessionCookie.value,
domain: HOST,
path: "/",
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: sessionCookie.httpOnly ?? true,
secure: false,
sameSite: "Lax",
},
]);
}
const page = await context.newPage();
page.on("console", (m) => {
if (m.type() === "error") console.log(" [browser console error]", m.text().slice(0, 200));
});
async function shot(slug, urlPath, prep) {
const url = `${BASE}${urlPath}`;
await page.goto(url, { waitUntil: "networkidle" }).catch(() => {});
await page.waitForTimeout(1500);
if (prep) await prep(page);
const target = path.join(outDir, `${slug}.png`);
await page.screenshot({ path: target, fullPage: false });
const title = await page.title().catch(() => "");
const bodyText = (await page.evaluate(() => document.body.innerText).catch(() => "")) || "";
results.push({ slug, url, title, hasTeams: bodyText.includes("Teams"), sample: bodyText.slice(0, 120).replace(/\n/g, " ") });
console.log(`captured ${target} :: title="${title}" :: ${bodyText.slice(0, 80).replace(/\n/g, " ")}`);
}
// 01 browse
await shot("01-browse", "/PAP/teams");
// 02 detail (click first team row)
await shot("02-detail", "/PAP/teams", async (p) => {
const row = p.locator('button:has-text("Team")').first();
if (await row.count()) await row.click().catch(() => {});
await p.waitForTimeout(800);
});
// 03 install wizard (click Install team)
await shot("03-install-wizard", "/PAP/teams", async (p) => {
const cta = p.locator('button:has-text("Install team")').first();
if (await cta.count()) await cta.click().catch(() => {});
await p.waitForTimeout(1500);
});
} finally {
await browser.close();
}
await fs.writeFile(path.join(outDir, "_capture-summary.json"), JSON.stringify(results, null, 2));
console.log("\nSummary:", JSON.stringify(results, null, 2));