diff --git a/.changeset/fix-dashboard-catalog-source.md b/.changeset/fix-dashboard-catalog-source.md new file mode 100644 index 00000000..497eaf9f --- /dev/null +++ b/.changeset/fix-dashboard-catalog-source.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": patch +--- + +Source the built-in dashboard catalog from the authenticated catalog API, load and search the complete compact index without the former result ceiling, and expose dedicated catalog detail routes with window-virtualized results. Revalidate catalog entries before typed-confirmation installs, keep missing or unreadable entries non-installable, remove the redundant source card and legacy in-page inspector, and isolate test lockfiles from the user's global Caplets state. diff --git a/apps/catalog/src/lib/catalog-store.ts b/apps/catalog/src/lib/catalog-store.ts index 8a045c1c..61f1814d 100644 --- a/apps/catalog/src/lib/catalog-store.ts +++ b/apps/catalog/src/lib/catalog-store.ts @@ -40,6 +40,14 @@ export async function listCatalogEntries(env: CatalogStoreEnv = {}): Promise>> { + return (await listCatalogEntries(env)).map( + ({ contentMarkdown: _contentMarkdown, ...entry }) => entry, + ); +} + export async function getCatalogEntry( entryKey: string, env: CatalogStoreEnv = {}, diff --git a/apps/catalog/src/pages/api/v1/catalog/index.ts b/apps/catalog/src/pages/api/v1/catalog/index.ts index ab16685c..b3ee1e4d 100644 --- a/apps/catalog/src/pages/api/v1/catalog/index.ts +++ b/apps/catalog/src/pages/api/v1/catalog/index.ts @@ -1,11 +1,14 @@ import type { APIContext } from "astro"; import { getCatalogEnv } from "../../../../lib/catalog-env"; -import { listCatalogEntries } from "../../../../lib/catalog-store"; +import { listCatalogEntries, listCompactCatalogEntries } from "../../../../lib/catalog-store"; import { jsonResponse } from "../../../../lib/catalog-response"; -export async function GET(_context: APIContext): Promise { +export async function GET(context: APIContext): Promise { + const compact = context.url.searchParams.get("view") === "compact"; + const env = getCatalogEnv(); return jsonResponse({ version: 1, - entries: await listCatalogEntries(getCatalogEnv()), + ...(compact ? { view: "compact" as const } : {}), + entries: compact ? await listCompactCatalogEntries(env) : await listCatalogEntries(env), }); } diff --git a/apps/catalog/test/catalog-api.test.ts b/apps/catalog/test/catalog-api.test.ts index 5065a578..57e6546b 100644 --- a/apps/catalog/test/catalog-api.test.ts +++ b/apps/catalog/test/catalog-api.test.ts @@ -1,6 +1,10 @@ import type { D1Database } from "@cloudflare/workers-types"; import { describe, expect, it } from "vitest"; -import { getCatalogEntry, listCatalogEntries } from "../src/lib/catalog-store"; +import { + getCatalogEntry, + listCatalogEntries, + listCompactCatalogEntries, +} from "../src/lib/catalog-store"; describe("catalog read model", () => { it("serves official entries with low-count display and generated install commands", async () => { @@ -19,6 +23,24 @@ describe("catalog read model", () => { expect(await getCatalogEntry(github?.entryKey ?? "")).toMatchObject({ id: "github" }); }); + it("projects a complete compact index without readable content", async () => { + const full = await listCatalogEntries(); + const compact = await listCompactCatalogEntries(); + + expect(compact).toHaveLength(full.length); + expect(compact[0]).toMatchObject({ + entryKey: expect.any(String), + installCount: expect.any(Number), + installCountDisplay: expect.any(String), + rankScore: expect.any(Number), + tags: expect.any(Array), + warnings: expect.any(Array), + source: expect.objectContaining({ repository: expect.any(String) }), + installCommand: expect.objectContaining({ text: expect.any(String) }), + }); + expect(compact.some((entry) => "contentMarkdown" in entry)).toBe(false); + }); + it("hides suppressed entries from list and detail reads", async () => { const githubEntryKey = "github:spiritledsoftware:caplets:github%2Fcaplet.md:github"; const entries = await listCatalogEntries({ diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index f555071e..1ee70a35 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -13,6 +13,7 @@ "@astrojs/react": "^6.0.1", "@base-ui/react": "^1.6.0", "@tailwindcss/vite": "^4.3.1", + "@tanstack/react-virtual": "^3.14.5", "astro": "^7.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/dashboard/src/components/DashboardApp.tsx b/apps/dashboard/src/components/DashboardApp.tsx index 629621db..94b68edc 100644 --- a/apps/dashboard/src/components/DashboardApp.tsx +++ b/apps/dashboard/src/components/DashboardApp.tsx @@ -3,7 +3,6 @@ import { useCallback, useContext, useEffect, - useMemo, useRef, useState, type ComponentProps, @@ -18,7 +17,6 @@ import { ClipboardListIcon, ComputerIcon, DatabaseIcon, - ExternalLinkIcon, EyeIcon, EyeOffIcon, HomeIcon, @@ -29,7 +27,6 @@ import { MenuIcon, MoonIcon, RefreshCwIcon, - SearchIcon, SettingsIcon, ShieldCheckIcon, SunIcon, @@ -97,6 +94,7 @@ import { import { EPHEMERAL_REVEAL_TTL_MS, createEphemeralRevealExpiry } from "@/lib/ephemeral-reveal"; import { dashboardBasePath, dashboardPath } from "@/lib/paths"; +import { CatalogPage } from "@/components/catalog/CatalogPage"; const REVEAL_DURATION_SECONDS = EPHEMERAL_REVEAL_TTL_MS / 1_000; const ACTION_DISCARDED = Symbol("dashboard-action-discarded"); @@ -751,9 +749,11 @@ function Page({ session: DashboardSession; action: (label: string, callback: () => Promise) => Promise; }) { + const { confirmTyped } = useActionConfirm(); if (route === "access") return ; if (route === "caplets") return ; - if (route === "catalog") return ; + if (route === "catalog") + return ; if (route === "vault") return ; if (route === "runtime") return ; if (route === "activity") return ; @@ -1543,767 +1543,6 @@ function riskSummaryLines(risk: unknown): { summary?: string; details: string[] }; } -type CatalogEntry = { - id: string; - name: string; - description: string; - tags?: string[]; - source?: { repository?: string }; - sourcePath?: string; - trustLevel?: string; - setupReadiness?: string; - authReadiness?: string; - projectBindingReadiness?: string; - workflow?: { label?: string; kind?: string }; - installCommand?: { text?: string; copyable?: boolean }; - warnings?: Array<{ code?: string; label: string; message?: string; severity?: string }>; - icon?: { type?: string; url?: string }; - contentMarkdown?: string; - resolvedRevision?: string; - indexedContentHash?: string; -}; - -type CatalogSearchResponse = { entries?: CatalogEntry[]; error?: string }; -type CatalogDetailResponse = { - entry?: CatalogEntry; - setupActions?: Array<{ kind: string; label: string; required: boolean }>; - error?: string; -}; - -const catalogSearchEndpoint = "catalog/search"; -const catalogDetailEndpoint = "catalog/detail"; -const catalogInstallEndpoint = "catalog/install"; - -function CatalogPage({ - data, - action, -}: { - data: DashboardData; - action: (label: string, callback: () => Promise) => Promise; -}) { - return ; -} - -function CatalogMirrorPage({ - data, - action, -}: { - data: DashboardData; - action: (label: string, callback: () => Promise) => Promise; -}) { - const { confirmTyped } = useActionConfirm(); - const [source, setSource] = useState("official"); - const [query, setQuery] = useState(""); - const [scope, setScope] = useState("all"); - const [setup, setSetup] = useState("all"); - const [tag, setTag] = useState("all"); - const [sort, setSort] = useState("rank"); - const [entries, setEntries] = useState([]); - const [selected, setSelected] = useState(); - const [detail, setDetail] = useState(); - const [loading, setLoading] = useState(true); - const [installing, setInstalling] = useState(); - const [error, setError] = useState(); - - useEffect(() => { - let cancelled = false; - setLoading(true); - const timer = window.setTimeout(() => { - const params = new URLSearchParams({ source, q: query, limit: "100" }); - dashboardApi(`${catalogSearchEndpoint}?${params}`) - .then((result) => { - if (cancelled) return; - const nextEntries = result.entries ?? []; - setEntries(nextEntries); - setSelected((current) => - current && nextEntries.some((entry) => entry.id === current.id) ? current : undefined, - ); - setError(undefined); - }) - .catch((searchError) => { - if (cancelled) return; - setEntries([]); - setSelected(undefined); - setError(searchError instanceof Error ? searchError.message : String(searchError)); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - }, 180); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [source, query]); - - useEffect(() => { - if (!selected) { - setDetail(undefined); - return; - } - const params = new URLSearchParams({ source, id: selected.id }); - dashboardApi(`${catalogDetailEndpoint}?${params}`) - .then(setDetail) - .catch((detailError) => - setDetail({ - error: detailError instanceof Error ? detailError.message : String(detailError), - }), - ); - }, [source, selected]); - - const tags = useMemo( - () => [...new Set(entries.flatMap((entry) => entry.tags ?? []))].sort(), - [entries], - ); - const visibleEntries = useMemo(() => { - const filtered = entries.filter((entry) => { - const trustMatches = scope === "all" || entry.trustLevel === scope; - const setupMatches = setup === "all" || entry.setupReadiness === setup; - const tagMatches = tag === "all" || (entry.tags ?? []).includes(tag); - return trustMatches && setupMatches && tagMatches; - }); - return [...filtered].sort((left, right) => - sort === "name" ? left.name.localeCompare(right.name) : rankEntry(right) - rankEntry(left), - ); - }, [entries, scope, setup, sort, tag]); - - async function install(entry: CatalogEntry) { - const expected = `install ${entry.id}`; - if (!(await confirmTyped("Install Caplet?", installReviewSummary(entry), expected))) { - return; - } - setInstalling(entry.id); - try { - await action(`Installed ${entry.name}`, () => - dashboardApi(catalogInstallEndpoint, { - method: "POST", - body: JSON.stringify({ source, capletId: entry.id }), - }), - ); - } finally { - setInstalling(undefined); - } - } - - return ( - -
-
-
-
-
-
Caplets Catalog
-
- {visibleEntries.length} Caplets · Install button actions replace install commands -
-
-
- -
- -
- Not security-reviewed. - Inspect Caplets before installing. -
- - - -
-
- Browse catalog - - Install readiness: {data.updates?.ready === false ? data.updates.reason : "ready"} - -
-
- - setQuery(event.target.value)} - type="search" - placeholder="Search Caplets" - aria-label="Search Caplets" - /> -
-
- - -
- -
- {loading - ? "Loading catalog results" - : error - ? "Catalog results failed to load" - : `${visibleEntries.length} catalog results loaded`} -
- {error ? : null} - {loading ? ( - - ) : ( - { - setSelected(entry); - window.setTimeout(() => { - const panel = document.getElementById("catalog-detail-panel"); - panel?.scrollIntoView({ block: "start" }); - if (panel instanceof HTMLElement) panel.focus(); - }, 0); - }} - onInstall={(entry) => { - setSelected(entry); - window.setTimeout(() => { - const panel = document.getElementById("catalog-detail-panel"); - panel?.scrollIntoView({ block: "start" }); - if (panel instanceof HTMLElement) panel.focus(); - }, 0); - }} - /> - )} -
-
- - void install(entry)} - /> -
- ); -} - -function CatalogFilterBar({ - scope, - setup, - tag, - sort, - tags, - onScopeChange, - onSetupChange, - onTagChange, - onSortChange, -}: { - scope: string; - setup: string; - tag: string; - sort: string; - tags: string[]; - onScopeChange: (value: string) => void; - onSetupChange: (value: string) => void; - onTagChange: (value: string) => void; - onSortChange: (value: string) => void; -}) { - return ( -
- - - - -
- ); -} - -function CatalogSelect({ - label, - value, - items, - onValueChange, -}: { - label: string; - value: string; - items: string[]; - onValueChange: (value: string) => void; -}) { - return ( - - ); -} - -function CatalogLegend() { - const items = [ - { label: "Official", icon: ShieldCheckIcon }, - { label: "Community", icon: ComputerIcon }, - { label: "External changes", icon: ExternalLinkIcon }, - { label: "Auth", icon: KeyIcon }, - { label: "Setup", icon: SettingsIcon }, - { label: "Binding", icon: LinkIcon }, - { label: "Unknown readiness", icon: AlertTriangleIcon }, - ]; - return ( -
- {items.map((item) => { - const Icon = item.icon; - return ( - - - ); - })} -
- ); -} - -function CatalogResultGrid({ - rows, - selectedId, - installing, - loading, - onSelect, - onInstall, -}: { - rows: CatalogEntry[]; - selectedId?: string; - installing?: string; - loading: boolean; - onSelect: (entry: CatalogEntry) => void; - onInstall: (entry: CatalogEntry) => void; -}) { - if (!rows.length) { - return ( -
-

No matching Caplets

-

- Reset filters or search for the service, workflow, or setup requirement you need. -

-
- ); - } - - return ( -
-
- {rows.map((row) => ( -
-
- -
- - {selectedId === row.id ? ( - - - ) : null} -
- {row.source?.repository ?? "local/source"} -
-
-
-

{row.description}

-
- -
- -
- ))} -
-
-
-
- Caplet - Description - Installs - Status - Install -
-
- {rows.map((row) => ( -
-
- -
- -
- {row.source?.repository ?? "local/source"} -
-
-
-

- {row.description} -

-
- {installCountDisplay(row)} -
-
- -
-
- -
-
- ))} -
-
-
-
- ); -} - -function CatalogStatusBadges({ row }: { row: CatalogEntry }) { - return ( - <> - - {row.trustLevel ?? "community"} - - {(row.warnings ?? []).slice(0, 2).map((warning) => ( - - {warning.label} - - ))} - - ); -} - -function CatalogReadinessReview({ - entry, - setupActions, -}: { - entry: CatalogEntry; - setupActions: Array<{ kind: string; label: string; required: boolean }>; -}) { - const checks = [ - { label: "Trust", value: entry.trustLevel ?? "community" }, - { label: "Auth", value: entry.authReadiness ?? "unknown" }, - { label: "Setup", value: entry.setupReadiness ?? "unknown" }, - { label: "Project Binding", value: entry.projectBindingReadiness ?? "unknown" }, - ]; - return ( -
-

- Installation readiness review -

-

- The install confirmation repeats the exact Caplet id after you inspect these trust and setup - checks. -

-
- {checks.map((check) => ( -
-
{check.label}
-
{check.value}
-
- ))} -
- {setupActions.length ? ( -
    - {setupActions.map((setupAction) => ( -
  • - - {setupAction.required ? "Required" : "Optional"} - - {setupAction.label} -
  • - ))} -
- ) : null} -
- ); -} - -function CatalogDetailPanel({ - entry, - error, - setupActions, - installing, - onInstall, -}: { - entry?: CatalogEntry; - error?: string; - setupActions: Array<{ kind: string; label: string; required: boolean }>; - installing?: string; - onInstall: (entry: CatalogEntry) => void; -}) { - if (error) return ; - if (!entry) { - return ( - - -

Inspect a Caplet

-

- Select a result to review trust, setup requirements, and manifest details before you - install it. -

-
-
- ); - } - const markdown = trimFrontmatter(entry.contentMarkdown ?? ""); - return ( -
- - -
- -
-
- - {entry.trustLevel ?? "community"} - - {entry.source?.repository ?? "local/source"} -
-

{entry.name}

- {entry.description} -
-
- -
- - - -
- - CAPLET.md - - {markdown ? ( -
-                {markdown}
-              
- ) : ( -
- -
- )} -
-
-
- - -

Record

-
- -
- - - - - - - - -
- {setupActions.length ? ( -
- {setupActions.map((setupAction) => ( - - {setupAction.label} - - ))} -
- ) : null} -
-
-
- ); -} - -function CatalogIcon({ entry, large = false }: { entry: CatalogEntry; large?: boolean }) { - const [failed, setFailed] = useState(false); - useEffect(() => setFailed(false), [entry.icon?.url]); - const iconUrl = entry.icon?.url; - const iconBlocked = iconUrl === "https://www.cloudflare.com/favicon.ico"; - if (iconUrl && !failed && !iconBlocked) { - return ( - setFailed(true)} - /> - ); - } - return ( - - ); -} - -function SafetyWarnings({ warnings }: { warnings: CatalogEntry["warnings"] }) { - if (!warnings?.length) return null; - return ( - - - Inspect before installing - -
    - {warnings.map((warning) => ( -
  • - {warning.label} - {warning.message ? — {warning.message} : null} -
  • - ))} -
-
-
- ); -} - -function CatalogLoadingRows() { - return ( -
- {[0, 1, 2, 3].map((item) => ( -
- ))} -
- ); -} - -function CatalogError({ message }: { message: string }) { - return ( - - - Catalog unavailable - {message} - - ); -} - function RecordRow({ label, value }: { label: string; value: string }) { return (
@@ -2313,35 +1552,6 @@ function RecordRow({ label, value }: { label: string; value: string }) { ); } -function rankEntry(entry: CatalogEntry): number { - return entry.trustLevel === "official" ? 1 : 0; -} - -function installCountDisplay(_entry: CatalogEntry): string { - return "<10"; -} - -function installReviewSummary(entry: CatalogEntry): string { - const warnings = entry.warnings?.length - ? entry.warnings.map((warning) => warning.label).join(", ") - : "none"; - return [ - `${entry.name} can add local capabilities.`, - `Trust: ${entry.trustLevel ?? "community"}.`, - `Auth: ${entry.authReadiness ?? "unknown"}.`, - `Setup: ${entry.setupReadiness ?? "unknown"}.`, - `Project Binding: ${entry.projectBindingReadiness ?? "unknown"}.`, - `Warnings: ${warnings}.`, - "Type the exact Caplet id only after reviewing this readiness summary.", - ].join(" "); -} - -function catalogLabel(value: string): string { - if (value === "all") return "All"; - if (value === "rank") return "Most relevant"; - return value.replace(/_/gu, " ").replace(/^./u, (char) => char.toUpperCase()); -} - function vaultKeyPresentation(rawKey: string): { label: string; kind: string; sensitive: boolean } { const upper = rawKey.toUpperCase(); const sensitive = /(TOKEN|SECRET|PASSWORD|PRIVATE|API_KEY|CLIENT_SECRET)/u.test(upper); @@ -2359,9 +1569,6 @@ function vaultKeyPresentation(rawKey: string): { label: string; kind: string; se }; } -function trimFrontmatter(markdown: string): string { - return markdown.replace(/^---[\s\S]*?---\s*/u, "").trim(); -} function VaultPage({ data, loading, diff --git a/apps/dashboard/src/components/catalog/CatalogDetailPage.test.tsx b/apps/dashboard/src/components/catalog/CatalogDetailPage.test.tsx new file mode 100644 index 00000000..2f87072b --- /dev/null +++ b/apps/dashboard/src/components/catalog/CatalogDetailPage.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CatalogDetailPage, installableDetail, type CatalogDetail } from "./CatalogDetailPage"; + +const detail: CatalogDetail = { + entry: { + entryKey: "stable:key", + id: "github", + name: "GitHub", + description: "Manage repositories", + tags: ["code"], + trustLevel: "official", + setupReadiness: "required", + authReadiness: "required", + projectBindingReadiness: "ready", + source: { repository: "caplets/github" }, + workflow: { label: "MCP" }, + installCountDisplay: "1k", + resolvedRevision: "abc123", + indexedContentHash: "sha256:abc", + sourcePath: "caplets/github/CAPLET.md", + contentMarkdown: "\n# GitHub", + installCommand: { text: "caplets install caplets/github github", copyable: true }, + warnings: [ + { + code: "external", + severity: "caution", + label: "External changes", + message: "Can modify repositories", + }, + ], + }, + setupActions: [{ kind: "auth", label: "Authenticate GitHub", required: true }], +}; +let host: HTMLDivElement; +let root: Root; +beforeEach(() => { + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); + +async function render(state: Parameters[0]["state"]) { + await act(async () => + root.render( + , + ), + ); +} + +describe("CatalogDetailPage", () => { + it("renders complete available metadata and inert selectable CAPLET.md", async () => { + await render({ status: "available", detail }); + expect(document.activeElement?.textContent).toBe("GitHub"); + expect(host.textContent).toContain("abc123"); + expect(host.textContent).toContain("sha256:abc"); + expect(host.textContent).toContain("Authenticate GitHub"); + expect(host.querySelector("script")).toBeNull(); + expect(host.querySelector("pre")?.textContent).toContain(""); + const repository = host.querySelector('a[target="_blank"]'); + expect(repository?.protocol).toBe("https:"); + expect(repository?.rel).toContain("noopener"); + expect(repository?.rel).toContain("noreferrer"); + }); + it("offers Retry only for transient failure and Return for every unsafe state", async () => { + await render({ status: "failed", message: "timeout" }); + expect(host.textContent).toContain("Retry"); + expect(host.textContent).toContain("Return to catalog"); + await render({ status: "unavailable", message: "missing" }); + expect(host.textContent).not.toContain("Retry"); + expect(host.textContent).toContain("Return to catalog"); + }); + + it("requires readable content and a copyable nonempty command", () => { + expect(installableDetail(detail)).toBe(true); + expect(installableDetail({ entry: { ...detail.entry, contentMarkdown: "" } })).toBe(false); + expect( + installableDetail({ + entry: { ...detail.entry, installCommand: { text: "", copyable: true } }, + }), + ).toBe(false); + expect( + installableDetail({ + entry: { ...detail.entry, installCommand: { text: "command", copyable: false } }, + }), + ).toBe(false); + }); +}); diff --git a/apps/dashboard/src/components/catalog/CatalogDetailPage.tsx b/apps/dashboard/src/components/catalog/CatalogDetailPage.tsx new file mode 100644 index 00000000..d365b106 --- /dev/null +++ b/apps/dashboard/src/components/catalog/CatalogDetailPage.tsx @@ -0,0 +1,270 @@ +import { useEffect, useRef, useState } from "react"; +import { AlertTriangleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"; +import type { CatalogCompactEntry } from "./catalog-state"; + +export type CatalogDetailEntry = CatalogCompactEntry & { + contentMarkdown?: string; + sourcePath?: string; + resolvedRevision?: string; + indexedContentHash?: string; +}; + +export type CatalogDetail = { + entry: CatalogDetailEntry; + setupActions?: Array<{ kind: string; label: string; required: boolean }>; +}; + +export type CatalogDetailState = + | { status: "loading" } + | { status: "available"; detail: CatalogDetail } + | { status: "unreadable"; detail: CatalogDetail; message: string } + | { status: "unavailable"; message: string } + | { status: "failed"; message: string }; + +export function installableDetail(detail: CatalogDetail | undefined): detail is CatalogDetail { + return Boolean( + detail?.entry.contentMarkdown?.length && + detail.entry.installCommand.copyable && + detail.entry.installCommand.text.trim(), + ); +} + +type CatalogDetailPageProps = { + state: CatalogDetailState; + installing: boolean; + onRetry(): void; + onReturn(): void; + onInstall(detail: CatalogDetail): void; + onCopy(command: string): Promise; +}; + +export function CatalogDetailPage({ + state, + installing, + onRetry, + onReturn, + onInstall, + onCopy, +}: CatalogDetailPageProps) { + const heading = useRef(null); + const [copyFailed, setCopyFailed] = useState(false); + + useEffect(() => { + if (state.status !== "loading") heading.current?.focus(); + }, [state]); + + if (state.status === "loading") { + return ( +
+

Loading Caplet details…

+
+ ); + } + + if (state.status === "unavailable" || state.status === "failed") { + return ( +
+

+ Caplet unavailable +

+ + + + {state.status === "failed" ? "Could not load Caplet" : "Caplet unavailable"} + + {state.message} + +
+ {state.status === "failed" && } + +
+
+ ); + } + + const { entry, setupActions = [] } = state.detail; + const safe = installableDetail(state.detail); + const repositoryUrl = safeRepositoryUrl(entry.source?.repository); + + async function copyCommand() { + try { + await onCopy(entry.installCommand.text); + setCopyFailed(false); + } catch { + setCopyFailed(true); + } + } + + return ( +
+ + + +
+ {entry.icon && ( + + )} +
+
+ {entry.trustLevel} + {repositoryUrl ? ( + + {entry.source?.repository} + + + ) : ( + {entry.source?.repository ?? "Source unavailable"} + )} +
+

+ {entry.name} +

+ {entry.description} +
+
+
+ + {state.status === "unreadable" && ( + + + Caplet content unreadable + {state.message} + + )} +
+

Installation readiness review

+
+ + + + +
+ {setupActions.map((action) => ( +
+ + {action.required ? "Required" : "Optional"} + {" "} + {action.label} +
+ ))} +
+ {entry.warnings?.map((warning) => ( + + + {warning.label} + {warning.message} + + ))} +
+

CAPLET.md

+ {entry.contentMarkdown ? ( +
+                {entry.contentMarkdown}
+              
+ ) : ( +

Readable Caplet content is unavailable.

+ )} +
+
+ + +
+ {copyFailed && ( + + {entry.installCommand.text} + + )} +
+
+ + +

Record

+
+ +
+ + + + + +
+
+
+
+ ); +} + +function safeRepositoryUrl(repository: string | undefined): string | undefined { + if (!repository) return; + + try { + const url = new URL( + repository.includes("://") ? repository : `https://github.com/${repository}`, + ); + return url.protocol === "https:" ? url.href : undefined; + } catch { + return; + } +} + +function DetailField({ + label, + value, + fallback, +}: { + label: string; + value: string | undefined; + fallback: string; +}) { + return ( +
+
{label}
+
{value ?? fallback}
+
+ ); +} + +function RecordField({ label, value }: { label: string; value: string | undefined }) { + return ( +
+
{label}
+
{value ?? "Not indexed"}
+
+ ); +} diff --git a/apps/dashboard/src/components/catalog/CatalogPage.test.tsx b/apps/dashboard/src/components/catalog/CatalogPage.test.tsx new file mode 100644 index 00000000..3dee5668 --- /dev/null +++ b/apps/dashboard/src/components/catalog/CatalogPage.test.tsx @@ -0,0 +1,456 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { dashboardApi, DashboardApiError } = vi.hoisted(() => { + class MockDashboardApiError extends Error { + status: number; + body: unknown; + constructor(message: string, options: { status: number; body: unknown }) { + super(message); + this.status = options.status; + this.body = options.body; + } + } + return { dashboardApi: vi.fn(), DashboardApiError: MockDashboardApiError }; +}); +vi.mock("@/lib/api", () => ({ dashboardApi, DashboardApiError })); + +import { CatalogPage } from "./CatalogPage"; +import type { CatalogCompactEntry } from "./catalog-state"; + +type Deferred = { promise: Promise; resolve(value: T): void; reject(error: unknown): void }; +let root: Root; +let host: HTMLDivElement; + +function deferred(): Deferred { + return Promise.withResolvers(); +} + +function entry(index: number, overrides: Partial = {}): CatalogCompactEntry { + return { + entryKey: `entry-${index}`, + id: `entry-${index}`, + name: `Entry ${String(index).padStart(3, "0")}`, + description: `Description ${index}`, + tags: [index % 2 ? "odd" : "even"], + trustLevel: index % 2 ? "community" : "official", + setupReadiness: index % 2 ? "required" : "ready", + installCommand: { text: `server command ${index}`, copyable: true }, + rankScore: index, + ...overrides, + }; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} +async function mount() { + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => { + root.render(); + }); +} +function setValue(element: HTMLInputElement | HTMLSelectElement, value: string) { + const prototype = + element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(prototype, "value")?.set?.call(element, value); + element.dispatchEvent(new Event("change", { bubbles: true })); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + dashboardApi.mockReset(); + window.history.replaceState({}, "", "/dashboard/catalog"); +}); +afterEach(async () => { + if (root) await act(async () => root.unmount()); + host?.remove(); +}); + +describe("CatalogPage", () => { + it("keeps server and first client render list-safe for a nested detail route", () => { + window.history.replaceState({}, "", "/dashboard/catalog/deep-link"); + const markup = renderToString( + , + ); + expect(markup).toContain("Catalog"); + expect(markup).not.toContain("Loading Caplet details"); + }); + + it("fetches one complete snapshot without q/limit and finds entries beyond 100", async () => { + dashboardApi.mockResolvedValue({ + entries: Array.from({ length: 150 }, (_, index) => + entry(index, index === 149 ? { name: "Deep Match" } : {}), + ), + }); + await mount(); + await flush(); + expect(dashboardApi).toHaveBeenCalledTimes(1); + expect(dashboardApi).toHaveBeenCalledWith( + "catalog/search?source=official", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + const search = document.querySelector('[aria-label="Search Caplets"]')!; + await act(async () => setValue(search, "Deep Match")); + expect(document.body.textContent).toContain("1 Caplet"); + expect(document.body.textContent).toContain("Deep Match"); + }); + + it("hydrates all controls, preserves unrelated params, uses replaceState, and replays popstate without rewriting", async () => { + window.history.replaceState( + {}, + "", + "/dashboard/catalog?keep=1&q=entry&scope=official&setup=ready&tag=even&sort=name", + ); + dashboardApi.mockResolvedValue({ entries: [entry(2)] }); + const replace = vi.spyOn(window.history, "replaceState"); + await mount(); + await flush(); + expect( + (document.querySelector('[aria-label="Search Caplets"]') as HTMLInputElement).value, + ).toBe("entry"); + expect( + (document.querySelector('[aria-label="Catalog scope"]') as HTMLSelectElement).value, + ).toBe("official"); + expect( + (document.querySelector('[aria-label="Catalog setup"]') as HTMLSelectElement).value, + ).toBe("ready"); + expect((document.querySelector('[aria-label="Catalog tag"]') as HTMLSelectElement).value).toBe( + "even", + ); + expect((document.querySelector('[aria-label="Catalog sort"]') as HTMLSelectElement).value).toBe( + "name", + ); + const search = document.querySelector('[aria-label="Search Caplets"]')!; + const writesBeforeChange = replace.mock.calls.length; + await act(async () => setValue(search, "changed")); + expect(window.location.search).toContain("keep=1"); + expect(replace.mock.calls.length).toBe(writesBeforeChange + 1); + const writes = replace.mock.calls.length; + window.history.replaceState({}, "", "/dashboard/catalog?keep=1&q=back"); + await act(async () => window.dispatchEvent(new PopStateEvent("popstate"))); + expect(search.value).toBe("back"); + expect(replace.mock.calls.length).toBe(writes + 1); + }); + + it("normalizes unknown URL values and Reset restores defaults and search focus", async () => { + window.history.replaceState( + {}, + "", + "/dashboard/catalog?keep=1&scope=x&setup=x&tag=x&sort=x&q=none", + ); + dashboardApi.mockResolvedValue({ entries: [entry(1)] }); + await mount(); + await flush(); + expect(document.body.textContent).toContain("No Caplets found"); + await act(async () => + ( + Array.from(document.querySelectorAll("button")).find( + (node) => node.textContent === "Reset", + ) as HTMLButtonElement + ).click(), + ); + expect(window.location.search).toBe("?keep=1"); + expect(document.activeElement).toBe(document.querySelector('[aria-label="Search Caplets"]')); + expect(document.body.textContent).toContain("1 Caplet"); + }); + + it("offers Retry after first-load failure", async () => { + dashboardApi + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce({ entries: [entry(1)] }); + await mount(); + await flush(); + expect(document.body.textContent).toContain("offline"); + await act(async () => + ( + Array.from(document.querySelectorAll("button")).find( + (node) => node.textContent === "Retry", + ) as HTMLButtonElement + ).click(), + ); + await flush(); + expect(dashboardApi).toHaveBeenCalledTimes(2); + expect(document.body.textContent).toContain("Entry 001"); + }); + + it("suppresses stale requests after retry", async () => { + const first = deferred<{ entries: CatalogCompactEntry[] }>(); + const second = deferred<{ entries: CatalogCompactEntry[] }>(); + dashboardApi.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + await mount(); + const firstSignal = dashboardApi.mock.calls[0]?.[1]?.signal as AbortSignal; + await act(async () => + (document.querySelector('[aria-label="Retry loading catalog"]') as HTMLButtonElement).click(), + ); + expect(firstSignal.aborted).toBe(true); + await act(async () => second.resolve({ entries: [entry(2, { name: "Current" })] })); + await act(async () => first.resolve({ entries: [entry(1, { name: "Stale" })] })); + expect(document.body.textContent).toContain("Current"); + expect(document.body.textContent).not.toContain("Stale"); + }); + + it("aborts a retry-created catalog request on unmount", async () => { + const first = deferred<{ entries: CatalogCompactEntry[] }>(); + const retry = deferred<{ entries: CatalogCompactEntry[] }>(); + dashboardApi.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise); + await mount(); + await act(async () => + (document.querySelector('[aria-label="Retry loading catalog"]') as HTMLButtonElement).click(), + ); + const retrySignal = dashboardApi.mock.calls[1]?.[1]?.signal as AbortSignal; + await act(async () => root.unmount()); + expect(retrySignal.aborted).toBe(true); + host.remove(); + }); + it("loads a stable deep link independently of the compact index", async () => { + window.history.replaceState({}, "", "/dashboard/catalog/stable%3Akey"); + const complete = { + entry: { + ...entry(7, { entryKey: "stable:key", id: "stable" }), + contentMarkdown: "# Stable", + resolvedRevision: "abc123", + indexedContentHash: "sha256:abc", + }, + setupActions: [], + }; + dashboardApi.mockImplementation((path: string) => + path.startsWith("catalog/detail?") + ? Promise.resolve(complete) + : Promise.resolve({ entries: [] }), + ); + await mount(); + await flush(); + expect(host.textContent).toContain("Entry 007"); + expect(host.textContent).toContain("abc123"); + expect(document.title).toBe("Entry 007 · Catalog · Caplets Dashboard"); + expect(dashboardApi).toHaveBeenCalledWith( + "catalog/detail?source=official&entryKey=stable%3Akey", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("aborts active detail work on unmount", async () => { + window.history.replaceState({}, "", "/dashboard/catalog/pending"); + const detail = deferred(); + dashboardApi.mockImplementation((path: string) => + path.startsWith("catalog/detail?") ? detail.promise : Promise.resolve({ entries: [] }), + ); + await mount(); + await flush(); + const signal = dashboardApi.mock.calls.find(([path]) => + String(path).startsWith("catalog/detail?"), + )?.[1]?.signal as AbortSignal; + await act(async () => root.unmount()); + expect(signal.aborted).toBe(true); + host.remove(); + }); + + it("distinguishes missing and unreadable detail responses", async () => { + window.history.replaceState({}, "", "/dashboard/catalog/missing"); + dashboardApi.mockImplementation((path: string) => + path.startsWith("catalog/detail?") + ? Promise.reject(new DashboardApiError("missing", { status: 404, body: {} })) + : Promise.resolve({ entries: [] }), + ); + await mount(); + await flush(); + expect(host.textContent).toContain("This Caplet is missing or unavailable."); + await act(async () => root.unmount()); + host.remove(); + + window.history.replaceState({}, "", "/dashboard/catalog/unreadable"); + dashboardApi.mockImplementation((path: string) => + path.startsWith("catalog/detail?") + ? Promise.resolve({ entry: { ...entry(8), contentMarkdown: "" }, setupActions: [] }) + : Promise.resolve({ entries: [] }), + ); + await mount(); + await flush(); + expect(host.textContent).toContain("Entry 008"); + expect(host.textContent).toContain("Caplet content unreadable"); + expect(host.querySelector("button:not([disabled])")?.textContent).not.toBe( + "Install", + ); + }); + it("revalidates a row before exact typed confirmation and posts the stable entry key", async () => { + const compact = entry(3, { entryKey: "stable:three", id: "three" }); + const complete = { entry: { ...compact, contentMarkdown: "# Three" }, setupActions: [] }; + const confirmTyped = vi.fn().mockResolvedValue(true); + const action = vi.fn( + async (_label: string, callback: () => Promise): Promise => { + await callback(); + }, + ); + dashboardApi.mockImplementation((path: string) => { + if (path === "catalog/search?source=official") return Promise.resolve({ entries: [compact] }); + if (path.startsWith("catalog/detail?")) return Promise.resolve(complete); + if (path === "catalog/install") return Promise.resolve({ installed: [] }); + return Promise.resolve({}); + }); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => + root.render(), + ); + await flush(); + await act(async () => + host.querySelector('[aria-label="Install Entry 003"]')?.click(), + ); + await flush(); + expect(confirmTyped).toHaveBeenCalledWith( + "Install Entry 003", + expect.any(String), + "install three", + ); + expect(dashboardApi).toHaveBeenCalledWith("catalog/install", { + method: "POST", + body: JSON.stringify({ source: "official", entryKey: "stable:three" }), + }); + }); + + it("keeps transient server failures non-installable and exposes Retry", async () => { + window.history.replaceState({}, "", "/dashboard/catalog/server-error"); + dashboardApi.mockImplementation((path: string) => + path.startsWith("catalog/detail?") + ? Promise.reject(new DashboardApiError("upstream failed", { status: 500, body: {} })) + : Promise.resolve({ entries: [] }), + ); + await mount(); + await flush(); + expect(host.textContent).toContain("upstream failed"); + expect(document.title).toBe("Caplet unavailable · Catalog · Caplets Dashboard"); + expect( + Array.from(host.querySelectorAll("button")).some((button) => button.textContent === "Retry"), + ).toBe(true); + expect( + Array.from(host.querySelectorAll("button")).some( + (button) => button.textContent === "Install", + ), + ).toBe(false); + }); + it("suppresses duplicate detail installs and clears the lock after action rejection", async () => { + window.history.replaceState({}, "", "/dashboard/catalog/stable"); + const complete = { + entry: { ...entry(1, { entryKey: "stable", id: "stable" }), contentMarkdown: "# Stable" }, + setupActions: [], + }; + const confirmation = deferred(); + const confirmTyped = vi.fn(() => confirmation.promise); + const action = vi + .fn() + .mockRejectedValueOnce(new Error("rejected")) + .mockResolvedValue(undefined); + dashboardApi.mockImplementation((path: string) => + path.startsWith("catalog/detail?") + ? Promise.resolve(complete) + : Promise.resolve({ entries: [] }), + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => + root.render(), + ); + await flush(); + const install = Array.from(host.querySelectorAll("button")).find( + (button) => button.textContent === "Install", + ); + await act(async () => { + install?.click(); + install?.click(); + }); + expect(confirmTyped).toHaveBeenCalledTimes(1); + await act(async () => confirmation.resolve(true)); + await flush(); + await act(async () => install?.click()); + await flush(); + expect(confirmTyped).toHaveBeenCalledTimes(2); + }); + + it("ignores a late A detail response after client navigation to B", async () => { + const a = deferred(); + const b = deferred(); + const entries = [ + entry(1, { entryKey: "A", name: "Alpha" }), + entry(2, { entryKey: "B", name: "Beta" }), + ]; + dashboardApi.mockImplementation((path: string) => + path.includes("entryKey=A") + ? a.promise + : path.includes("entryKey=B") + ? b.promise + : Promise.resolve({ entries }), + ); + await mount(); + await flush(); + await act(async () => host.querySelector('a[href$="/A"]')?.click()); + const aSignal = dashboardApi.mock.calls.find(([path]) => + String(path).includes("entryKey=A"), + )?.[1]?.signal as AbortSignal; + window.history.pushState({ catalogListHref: "/dashboard/catalog" }, "", "/dashboard/catalog/B"); + await act(async () => window.dispatchEvent(new PopStateEvent("popstate"))); + await flush(); + expect(aSignal.aborted).toBe(true); + await act(async () => + b.resolve({ entry: { ...entries[1], contentMarkdown: "# B" }, setupActions: [] }), + ); + await flush(); + await act(async () => + a.resolve({ entry: { ...entries[0], contentMarkdown: "# A" }, setupActions: [] }), + ); + await flush(); + expect(host.textContent).toContain("Beta"); + expect(host.textContent).not.toContain("Alpha"); + }); + + it("preserves list query across breadcrumb and focuses the originating row or heading fallback", async () => { + window.history.replaceState({}, "", "/dashboard/catalog?q=Entry"); + dashboardApi + .mockResolvedValueOnce({ entries: [entry(1)] }) + .mockResolvedValue({ entry: { ...entry(1), contentMarkdown: "# One" }, setupActions: [] }); + await mount(); + await flush(); + await act(async () => host.querySelector('a[href$="/entry-1"]')?.click()); + await flush(); + await act(async () => + Array.from(host.querySelectorAll("button")) + .find((button) => button.textContent === "Catalog") + ?.click(), + ); + await flush(); + expect(window.location.search).toBe("?q=Entry"); + expect(document.activeElement?.textContent).toBe("Entry 001"); + window.history.pushState( + { catalogListHref: "/dashboard/catalog?q=missing" }, + "", + "/dashboard/catalog/missing", + ); + await act(async () => window.dispatchEvent(new PopStateEvent("popstate"))); + await flush(); + window.history.pushState({}, "", "/dashboard/catalog?q=missing"); + await act(async () => window.dispatchEvent(new PopStateEvent("popstate"))); + await flush(); + expect(document.activeElement?.id).toBe("catalog-title"); + }); + it("offers unknown setup readiness and restores the list title", async () => { + document.title = "Operator"; + dashboardApi.mockResolvedValue({ entries: [entry(1, { setupReadiness: "unknown" })] }); + await mount(); + await flush(); + expect(document.title).toBe("Catalog · Caplets Dashboard"); + const setup = document.querySelector('[aria-label="Catalog setup"]')!; + expect(Array.from(setup.options, (option) => option.value)).toContain("unknown"); + await act(async () => setValue(setup, "unknown")); + expect(host.textContent).toContain("Entry 001"); + }); +}); diff --git a/apps/dashboard/src/components/catalog/CatalogPage.tsx b/apps/dashboard/src/components/catalog/CatalogPage.tsx new file mode 100644 index 00000000..c242a4cd --- /dev/null +++ b/apps/dashboard/src/components/catalog/CatalogPage.tsx @@ -0,0 +1,484 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent, + type ReactNode, +} from "react"; +import { AlertTriangleIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { toast } from "sonner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { DashboardApiError, dashboardApi } from "@/lib/api"; +import { + CatalogDetailPage, + installableDetail, + type CatalogDetail, + type CatalogDetailState, +} from "./CatalogDetailPage"; +import { CatalogResults } from "./CatalogResults"; +import { catalogDetailHref, catalogListHref, catalogLocationFromPath } from "./catalog-route"; +import type { CatalogHistoryState, CatalogLocation } from "./catalog-route"; +import { + catalogTags, + catalogStateFromLocation, + defaultCatalogState, + filterCatalogEntries, + parseCatalogState, + updateCatalogUrl, + type CatalogCompactEntry, + type CatalogCompactResponse, + type CatalogDiscoveryState, +} from "./catalog-state"; + +export type CatalogPageProps = { + data: { updates?: { ready?: boolean; reason?: string } }; + action: (label: string, callback: () => Promise) => Promise; + confirmTyped?: (title: string, description: string, expectedPhrase: string) => Promise; +}; + +const endpoint = "catalog/search?source=official"; +const DETAIL_TIMEOUT_MS = 10_000; +const SCOPE_OPTIONS = ["all", "official", "community"]; +const SETUP_OPTIONS = ["all", "ready", "required", "unknown"]; +const SORT_OPTIONS = ["rank", "name"]; + +export function CatalogPage({ data, action, confirmTyped = async () => false }: CatalogPageProps) { + const [entries, setEntries] = useState([]); + const [discovery, setDiscovery] = useState(defaultCatalogState); + const [location, setLocation] = useState({ mode: "list" }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [detailState, setDetailState] = useState({ status: "loading" }); + const [installingKey, setInstallingKey] = useState(); + const requestSequence = useRef(0); + const detailSequence = useRef(0); + const activeRequestController = useRef(undefined); + const activeDetailController = useRef(undefined); + const installLock = useRef(false); + const searchRef = useRef(null); + const tags = useMemo(() => catalogTags(entries), [entries]); + const visible = useMemo(() => filterCatalogEntries(entries, discovery), [entries, discovery]); + const discoveryKey = `${discovery.query}\u0000${discovery.scope}\u0000${discovery.setup}\u0000${discovery.tag}\u0000${discovery.sort}`; + + const load = useCallback(() => { + activeRequestController.current?.abort(); + const request = ++requestSequence.current; + const controller = new AbortController(); + activeRequestController.current = controller; + setLoading(true); + setError(undefined); + + void dashboardApi(endpoint, { signal: controller.signal }) + .then((response) => { + if (request !== requestSequence.current) return; + + const nextEntries = response.entries ?? []; + const normalized = parseCatalogState( + new URLSearchParams(window.location.search), + catalogTags(nextEntries), + ); + setEntries(nextEntries); + setDiscovery(normalized); + + if (catalogLocationFromPath(window.location.pathname).mode !== "list") return; + const nextUrl = updateCatalogUrl(window.location.href, normalized); + const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (nextUrl !== currentUrl) { + window.history.replaceState(window.history.state, "", nextUrl); + } + }) + .catch((reason: unknown) => { + if (request === requestSequence.current) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }) + .finally(() => { + if (request === requestSequence.current) setLoading(false); + if (activeRequestController.current === controller) { + activeRequestController.current = undefined; + } + }); + + return controller; + }, []); + + const fetchDetail = useCallback(async (entryKey: string): Promise => { + activeDetailController.current?.abort(); + const request = ++detailSequence.current; + const controller = new AbortController(); + activeDetailController.current = controller; + const timeout = window.setTimeout(() => controller.abort(), DETAIL_TIMEOUT_MS); + setDetailState({ status: "loading" }); + + try { + const detail = await dashboardApi( + `catalog/detail?source=official&entryKey=${encodeURIComponent(entryKey)}`, + { signal: controller.signal }, + ); + if (request !== detailSequence.current) return; + + if (!installableDetail(detail)) { + setDetailState({ + status: "unreadable", + detail, + message: "Readable, copyable Caplet content is unavailable.", + }); + return; + } + + setDetailState({ status: "available", detail }); + return detail; + } catch (reason) { + if (request !== detailSequence.current) return; + + if (reason instanceof DashboardApiError && reason.status === 404) { + setDetailState({ + status: "unavailable", + message: "This Caplet is missing or unavailable.", + }); + } else { + const message = + reason instanceof Error && reason.name !== "AbortError" + ? reason.message + : "The detail request timed out."; + setDetailState({ status: "failed", message }); + } + return; + } finally { + window.clearTimeout(timeout); + if (activeDetailController.current === controller) { + activeDetailController.current = undefined; + } + } + }, []); + + useEffect(() => { + setDiscovery(catalogStateFromLocation()); + setLocation(catalogLocationFromPath(window.location.pathname)); + }, []); + useEffect(() => { + let title = "Catalog · Caplets Dashboard"; + if (location.mode === "detail") { + if (detailState.status === "available" || detailState.status === "unreadable") { + title = `${detailState.detail.entry.name} · Catalog · Caplets Dashboard`; + } else if (detailState.status === "unavailable" || detailState.status === "failed") { + title = "Caplet unavailable · Catalog · Caplets Dashboard"; + } + } + document.title = title; + }, [detailState, location.mode]); + + useEffect(() => { + load(); + return () => { + ++requestSequence.current; + activeRequestController.current?.abort(); + activeRequestController.current = undefined; + }; + }, [load]); + + useEffect(() => { + if (location.mode === "detail") { + void fetchDetail(location.entryKey); + } else { + ++detailSequence.current; + activeDetailController.current?.abort(); + activeDetailController.current = undefined; + } + }, [fetchDetail, location]); + + useEffect( + () => () => { + ++detailSequence.current; + activeDetailController.current?.abort(); + activeDetailController.current = undefined; + }, + [], + ); + + function focusList(previousEntryKey = "") { + window.setTimeout(() => { + const row = document.querySelector( + `[data-entry-key="${CSS.escape(previousEntryKey)}"] a`, + ); + const target = + row ?? document.querySelector("#catalog-title") ?? searchRef.current; + target?.focus(); + }, 0); + } + + function restoreListState() { + setDiscovery(parseCatalogState(new URLSearchParams(window.location.search), tags)); + } + + useEffect(() => { + const replay = () => { + const previousEntryKey = location.mode === "detail" ? location.entryKey : ""; + const next = catalogLocationFromPath(window.location.pathname); + setLocation(next); + if (next.mode !== "list") return; + + restoreListState(); + focusList(previousEntryKey); + }; + window.addEventListener("popstate", replay); + return () => window.removeEventListener("popstate", replay); + }, [location, tags]); + + function listHrefFromHistory(): string { + const candidate = (window.history.state as Partial | null) + ?.catalogListHref; + return typeof candidate === "string" ? candidate : catalogListHref(window.location.pathname); + } + + function returnToList() { + const previousEntryKey = location.mode === "detail" ? location.entryKey : ""; + window.history.pushState({}, "", listHrefFromHistory()); + setLocation({ mode: "list" }); + restoreListState(); + focusList(previousEntryKey); + } + + function openDetail(event: MouseEvent, entry: CatalogCompactEntry) { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + + event.preventDefault(); + const origin = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.history.pushState( + { catalogListHref: origin } satisfies CatalogHistoryState, + "", + catalogDetailHref(entry.entryKey, window.location.pathname), + ); + setLocation({ mode: "detail", entryKey: entry.entryKey }); + } + + function change(patch: Partial) { + const next = { ...discovery, ...patch }; + window.history.replaceState( + window.history.state, + "", + updateCatalogUrl(window.location.href, next), + ); + setDiscovery(next); + } + + async function copy(command: string, name?: string) { + try { + await navigator.clipboard.writeText(command); + toast.success(name ? `Copied install command for ${name}` : "Install command copied"); + } catch (reason) { + toast.error(reason instanceof Error ? `Copy failed: ${reason.message}` : "Copy failed"); + throw reason; + } + } + + async function install(detail: CatalogDetail) { + if (!installableDetail(detail) || installingKey || installLock.current) return; + + installLock.current = true; + const phrase = `install ${detail.entry.id}`; + try { + const confirmed = await confirmTyped( + `Install ${detail.entry.name}`, + "Confirm the exact Caplet id before installing from the official catalog.", + phrase, + ); + if (!confirmed) return; + + setInstallingKey(detail.entry.entryKey); + await action(`Installed ${detail.entry.name}`, async () => + dashboardApi("catalog/install", { + method: "POST", + body: JSON.stringify({ source: "official", entryKey: detail.entry.entryKey }), + }), + ); + } catch (reason) { + toast.error(reason instanceof Error ? `Install failed: ${reason.message}` : "Install failed"); + } finally { + installLock.current = false; + setInstallingKey(undefined); + } + } + + async function rowInstall(entry: CatalogCompactEntry) { + if (installingKey) return; + + setInstallingKey(entry.entryKey); + const detail = await fetchDetail(entry.entryKey); + setInstallingKey(undefined); + if (detail) { + await install(detail); + } else { + toast.error(`Could not verify ${entry.name} for installation`); + } + } + + function resetDiscovery() { + change(defaultCatalogState); + window.setTimeout(() => searchRef.current?.focus(), 0); + } + + if (location.mode === "detail") { + return ( + void fetchDetail(location.entryKey)} + onReturn={returnToList} + onInstall={(detail) => void install(detail)} + onCopy={copy} + /> + ); + } + + let results: ReactNode; + if (loading) { + results = ( +
+

Loading catalog…

+ +
+ ); + } else if (error) { + results = ( + + + Catalog unavailable + {error} + + + ); + } else if (visible.length > 0) { + results = ( + copy(command, entry.name)} + onInstall={(entry) => void rowInstall(entry)} + /> + ); + } else { + results = ( +
+

No Caplets found

+ +
+ ); + } + + return ( +
+
+

+ Catalog +

+

+ Search the same catalog surface as caplets.dev, then install directly from this operator + console. +

+
+
+ Not security-reviewed. + Inspect Caplets before installing. +
+ + +
+
+ Browse catalog + + Install readiness: {data.updates?.ready === false ? data.updates.reason : "ready"} + +
+
+ + change({ query: event.target.value })} + type="search" + placeholder="Search Caplets" + aria-label="Search Caplets" + /> +
+
+
+ change({ scope })} + options={SCOPE_OPTIONS} + /> + change({ setup })} + options={SETUP_OPTIONS} + /> + change({ tag })} + options={["all", ...tags]} + /> + change({ sort: sort === "name" ? "name" : "rank" })} + options={SORT_OPTIONS} + /> +
+
+ {results} +
+
+ ); +} + +type CatalogSelectProps = { + label: string; + value: string; + options: readonly string[]; + onChange(value: string): void; +}; + +function CatalogSelect({ label, value, options, onChange }: CatalogSelectProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/components/catalog/CatalogResults.test.tsx b/apps/dashboard/src/components/catalog/CatalogResults.test.tsx new file mode 100644 index 00000000..380dd749 --- /dev/null +++ b/apps/dashboard/src/components/catalog/CatalogResults.test.tsx @@ -0,0 +1,214 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { success, error } = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success, error } })); + +import { CATALOG_RESULTS_OVERSCAN, CatalogResults, catalogRowEstimate } from "./CatalogResults"; +import type { CatalogCompactEntry } from "./catalog-state"; + +let root: Root; +let host: HTMLDivElement; +const install = vi.fn(); + +function entry(index: number): CatalogCompactEntry { + return { + entryKey: `key-${index}`, + id: `id-${index}`, + name: `Entry ${index}`, + description: `Description ${index}`, + tags: [], + trustLevel: index % 2 ? "community" : "official", + setupReadiness: "ready", + installCommand: { text: `server install command ${index}`, copyable: true }, + installCount: index, + }; +} + +async function render( + entries: CatalogCompactEntry[], + discoveryKey = "initial", + onNavigate?: React.ComponentProps["onNavigate"], +) { + await act(async () => + root.render( + { + try { + await navigator.clipboard.writeText(command); + success(`Copied install command for ${item.name}`); + } catch (reason) { + error(reason instanceof Error ? `Copy failed: ${reason.message}` : "Copy failed"); + throw reason; + } + }} + />, + ), + ); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + Object.defineProperties(window, { + innerWidth: { configurable: true, value: 1200 }, + innerHeight: { configurable: true, value: 768 }, + scrollY: { configurable: true, value: 0 }, + }); + window.matchMedia = vi.fn((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + window.scrollTo = vi.fn(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + success.mockReset(); + error.mockReset(); + install.mockReset(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); + +describe("CatalogResults", () => { + it("uses fixed-height estimates aligned to Tailwind sm and lg boundaries", () => { + expect([1200, 1024, 1023, 960, 640, 639, 500, 421, 420, 320].map(catalogRowEstimate)).toEqual([ + 112, 112, 188, 188, 188, 320, 320, 320, 320, 320, + ]); + }); + + it("keeps 10,000 results bounded and exposes table count and row indices", async () => { + await render(Array.from({ length: 10_000 }, (_, index) => entry(index))); + const rows = document.querySelectorAll("[data-result-row]"); + expect(rows.length).toBeGreaterThan(0); + expect(rows.length).toBeLessThanOrEqual( + Math.ceil(768 / catalogRowEstimate(1200)) + CATALOG_RESULTS_OVERSCAN * 2 + 2, + ); + expect(document.querySelector('[role="table"]')?.getAttribute("aria-rowcount")).toBe("10001"); + expect(rows[0]?.getAttribute("aria-rowindex")).toBe("2"); + expect(rows[0]?.style.height).toBe("112px"); + expect(rows[0]?.className).toContain("overflow-hidden"); + expect(document.querySelector("[data-result-spacer]")?.getAttribute("class")).not.toContain( + "overflow-y", + ); + }); + + it("uses stable entry keys across ordering changes and resets without stealing control focus", async () => { + const entries = Array.from({ length: 100 }, (_, index) => entry(index)); + await render(entries); + const input = document.createElement("input"); + document.body.append(input); + input.focus(); + await render([...entries].reverse()); + expect(document.activeElement).toBe(input); + const mountedKeys = [...document.querySelectorAll("[data-result-row]")].map( + (row) => row.dataset.entryKey, + ); + expect(new Set(mountedKeys).size).toBe(mountedKeys.length); + expect(window.scrollTo).toHaveBeenCalled(); + input.remove(); + }); + + it("resets scroll when discovery changes without changing result identity", async () => { + const entries = [entry(1), entry(2)]; + await render(entries, "q=one"); + vi.mocked(window.scrollTo).mockClear(); + const input = document.createElement("input"); + document.body.append(input); + input.focus(); + await render(entries, "q=two"); + expect(window.scrollTo).toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + input.remove(); + }); + + it("navigates once from the detail link and retains navigation from row space", async () => { + const navigate = vi.fn((event: React.MouseEvent) => event.preventDefault()); + await render([entry(1)], "initial", navigate); + const link = document.querySelector("[data-result-row] a")!; + await act(async () => link.click()); + expect(navigate).toHaveBeenCalledTimes(1); + await act(async () => document.querySelector("[data-result-row] > p")!.click()); + expect(navigate).toHaveBeenCalledTimes(2); + expect(install).not.toHaveBeenCalled(); + await act(async () => + (document.querySelector('[aria-label="Install Entry 1"]') as HTMLButtonElement).click(), + ); + expect(install).toHaveBeenCalledWith(expect.objectContaining({ entryKey: "key-1" })); + }); + it("renders server icon metadata with failure fallback and bounded safety signals", async () => { + await render([ + { + ...entry(3), + icon: { type: "url", url: "https://cdn.example/icon.png" }, + projectBindingReadiness: "required", + warnings: [ + { code: "one", severity: "caution", label: "Inspect", message: "Review source" }, + { code: "two", severity: "danger", label: "Mutates", message: "Changes external state" }, + { code: "three", severity: "info", label: "More", message: "Additional warning" }, + ], + }, + ]); + const icon = document.querySelector("img"); + expect(icon?.src).toBe("https://cdn.example/icon.png"); + expect(icon?.loading).toBe("lazy"); + expect(icon?.referrerPolicy).toBe("no-referrer"); + expect(document.body.textContent).toContain("Project: required"); + expect(document.querySelector('[aria-label="Inspect: Review source"]')).not.toBeNull(); + expect(document.querySelector('[aria-label="1 more warnings"]')).not.toBeNull(); + await act(async () => icon?.dispatchEvent(new Event("error"))); + expect(document.querySelector("[data-icon-fallback]")?.textContent).toBe("E"); + }); + + it("copies commands and exposes the full selectable command on rejection", async () => { + await render([entry(1)]); + const copy = document.querySelector( + '[aria-label="Copy install command for Entry 1"]', + ) as HTMLButtonElement; + await act(async () => copy.click()); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("server install command 1"); + expect(success).toHaveBeenCalled(); + vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error("denied")); + await act(async () => copy.click()); + const command = document.querySelector("code[role=status]")!; + expect(command.textContent).toBe("server install command 1"); + expect(command.className).toContain("select-all"); + expect(command.getAttribute("aria-live")).toBe("assertive"); + expect(error).toHaveBeenCalledWith("Copy failed: denied"); + }); + it("does not offer Copy when the server marks its command non-copyable", async () => { + await render([ + { ...entry(2), installCommand: { text: "server-only command", copyable: false } }, + ]); + expect(document.querySelector('[aria-label="Copy install command for Entry 2"]')).toBeNull(); + expect(document.querySelector("code")?.textContent).toBe("server-only command"); + }); + + it("remeasures on resize/media changes and removes every listener on cleanup", async () => { + const add = vi.spyOn(window, "addEventListener"); + const remove = vi.spyOn(window, "removeEventListener"); + await render([entry(1)]); + await act(async () => window.dispatchEvent(new Event("resize"))); + await act(async () => root.unmount()); + expect(add.mock.calls.some(([name]) => name === "resize")).toBe(true); + expect(remove.mock.calls.some(([name]) => name === "resize")).toBe(true); + root = createRoot(host); + }); +}); diff --git a/apps/dashboard/src/components/catalog/CatalogResults.tsx b/apps/dashboard/src/components/catalog/CatalogResults.tsx new file mode 100644 index 00000000..8fb655a2 --- /dev/null +++ b/apps/dashboard/src/components/catalog/CatalogResults.tsx @@ -0,0 +1,308 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useWindowVirtualizer, type VirtualItem } from "@tanstack/react-virtual"; +import { CheckCircle2Icon, CopyIcon, DownloadIcon, ShieldCheckIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { catalogDetailHref } from "./catalog-route"; +import type { CatalogCompactEntry } from "./catalog-state"; + +export const CATALOG_RESULTS_OVERSCAN = 8; + +export function catalogRowEstimate(width: number): number { + if (width < 640) return 320; + if (width < 1024) return 188; + return 112; +} + +export type CatalogResultsProps = { + visible: CatalogCompactEntry[]; + discoveryKey: string; + installingKey?: string; + onInstall(entry: CatalogCompactEntry): void; + onCopy(command: string, entry: CatalogCompactEntry): Promise; + onNavigate?(event: ReactMouseEvent, entry: CatalogCompactEntry): void; +}; + +export function CatalogResults(props: CatalogResultsProps) { + if (typeof window === "undefined") { + return
; + } + return ; +} + +function CatalogResultsClient({ + visible, + installingKey, + discoveryKey, + onInstall, + onCopy, + onNavigate = (event) => { + if (!event.defaultPrevented) return; + }, +}: CatalogResultsProps) { + const spacerRef = useRef(null); + const [scrollMargin, setScrollMargin] = useState(0); + const identity = useMemo(() => visible.map((entry) => entry.entryKey).join("\u0000"), [visible]); + const virtualizer = useWindowVirtualizer({ + count: visible.length, + estimateSize: () => catalogRowEstimate(window.innerWidth), + getItemKey: (index) => visible[index]?.entryKey ?? index, + overscan: CATALOG_RESULTS_OVERSCAN, + scrollMargin, + initialRect: { width: window.innerWidth, height: window.innerHeight }, + }); + + useLayoutEffect(() => { + const top = (spacerRef.current?.getBoundingClientRect().top ?? 0) + window.scrollY; + setScrollMargin((current) => (current === top ? current : top)); + virtualizer.measure(); + }, [identity, virtualizer]); + + useEffect(() => { + const active = document.activeElement; + virtualizer.scrollToIndex(0, { align: "start" }); + if (active instanceof HTMLElement && active.matches("input,select,textarea,button,a[href]")) + active.focus(); + }, [discoveryKey, virtualizer]); + + useEffect(() => { + const media = [640, 1024].map((width) => window.matchMedia(`(min-width: ${width}px)`)); + const remeasure = () => virtualizer.measure(); + window.addEventListener("resize", remeasure); + media.forEach((query) => query.addEventListener("change", remeasure)); + return () => { + window.removeEventListener("resize", remeasure); + media.forEach((query) => query.removeEventListener("change", remeasure)); + }; + }, [virtualizer]); + + const items = virtualizer.getVirtualItems(); + const total = Math.max( + virtualizer.getTotalSize(), + visible.length ? catalogRowEstimate(window.innerWidth) : 1, + ); + + function rowClick(event: ReactMouseEvent, entry: CatalogCompactEntry) { + if ((event.target as Element).closest("a[href],button,input,select,textarea,[data-row-action]")) + return; + onNavigate(event, entry); + } + + return ( +
+

+ {visible.length} {visible.length === 1 ? "Caplet" : "Caplets"} +

+
+
+ {["Name", "Description", "Installs", "Status", "Actions"].map((heading) => ( + + {heading} + + ))} +
+
+ {items.map((item) => { + const entry = visible[item.index]; + if (!entry) return null; + return ( + + ); + })} +
+
+
+ ); +} + +type CatalogResultRowProps = { + entry: CatalogCompactEntry; + item: VirtualItem; + scrollMargin: number; + installing: boolean; + onInstall(entry: CatalogCompactEntry): void; + onCopy(command: string, entry: CatalogCompactEntry): Promise; + onNavigate(event: ReactMouseEvent, entry: CatalogCompactEntry): void; + onClick(event: ReactMouseEvent, entry: CatalogCompactEntry): void; +}; + +function CatalogResultRow({ + entry, + item, + scrollMargin, + installing, + onInstall, + onCopy, + onNavigate, + onClick, +}: CatalogResultRowProps) { + const command = entry.installCommand.text; + const [copyFailed, setCopyFailed] = useState(false); + const [iconFailed, setIconFailed] = useState(false); + const icon = iconFailed ? undefined : entry.icon; + const displayedWarnings = entry.warnings?.slice(0, 2); + const remainingWarnings = Math.max((entry.warnings?.length ?? 0) - 2, 0); + + async function copy() { + try { + await onCopy(command, entry); + setCopyFailed(false); + } catch { + setCopyFailed(true); + } + } + + return ( +
onClick(event, entry)} + className="absolute left-0 top-0 grid w-full min-w-0 cursor-pointer grid-cols-1 gap-2 overflow-hidden border-b p-4 sm:grid-cols-[minmax(10rem,1fr)_minmax(12rem,2fr)_auto] lg:grid-cols-[minmax(12rem,1fr)_minmax(14rem,2fr)_auto_auto_auto] lg:items-center" + style={{ + height: item.size, + transform: `translateY(${item.start - scrollMargin}px)`, + }} + > +
+ {icon ? ( + setIconFailed(true)} + /> + ) : ( + + )} + onNavigate(event, entry)} + > + {entry.name} + + {entry.trustLevel === "official" && ( + + )} +
+

+ {entry.description} +

+
+ {entry.installCountDisplay ?? entry.installCount?.toLocaleString() ?? "—"} +
+
+ + {entry.trustLevel} + + + + Setup: {entry.setupReadiness} + + {entry.authReadiness && Auth: {entry.authReadiness}} + {entry.projectBindingReadiness && ( + Project: {entry.projectBindingReadiness} + )} + {displayedWarnings?.map((warning) => ( + + {warning.label} + + ))} + {remainingWarnings > 0 && ( + + +{remainingWarnings} + + )} +
+
+ {entry.installCommand.copyable ? ( + + ) : ( + + {command} + + )} + + {copyFailed && ( + + {command} + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/catalog/catalog-route.test.ts b/apps/dashboard/src/components/catalog/catalog-route.test.ts new file mode 100644 index 00000000..d4c7d3a0 --- /dev/null +++ b/apps/dashboard/src/components/catalog/catalog-route.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { catalogDetailHref, catalogListHref, catalogLocationFromPath } from "./catalog-route"; + +describe("catalog route helpers", () => { + it("round-trips repository-qualified entry keys under root and configured base paths", () => { + const entryKey = "github:spiritledsoftware/caplets:google-workspace/CAPLET.md"; + + const rootHref = catalogDetailHref(entryKey, "/dashboard/catalog"); + const baseHref = catalogDetailHref(entryKey, "/caplets/dashboard/catalog"); + + expect(rootHref).toBe(`/dashboard/catalog/${encodeURIComponent(entryKey)}`); + expect(baseHref).toBe(`/caplets/dashboard/catalog/${encodeURIComponent(entryKey)}`); + expect(catalogLocationFromPath(rootHref)).toEqual({ mode: "detail", entryKey }); + expect(catalogLocationFromPath(baseHref)).toEqual({ mode: "detail", entryKey }); + expect(catalogListHref(baseHref)).toBe("/caplets/dashboard/catalog"); + }); + + it("keeps catalog list mode for malformed, empty, or nested path identities", () => { + expect(catalogLocationFromPath("/dashboard/catalog")).toEqual({ mode: "list" }); + expect(catalogLocationFromPath("/dashboard/catalog/%")).toEqual({ mode: "list" }); + expect(catalogLocationFromPath("/dashboard/catalog/%2e%2e%2fsecret")).toEqual({ + mode: "list", + }); + expect(catalogLocationFromPath("/dashboard/catalog/key%00value")).toEqual({ mode: "list" }); + expect(catalogLocationFromPath("/dashboard/catalog/key/extra")).toEqual({ mode: "list" }); + }); +}); diff --git a/apps/dashboard/src/components/catalog/catalog-route.ts b/apps/dashboard/src/components/catalog/catalog-route.ts new file mode 100644 index 00000000..20605b3b --- /dev/null +++ b/apps/dashboard/src/components/catalog/catalog-route.ts @@ -0,0 +1,51 @@ +import { dashboardBasePath, dashboardPath } from "@/lib/paths"; + +export type CatalogLocation = { mode: "list" } | { mode: "detail"; entryKey: string }; + +export type CatalogHistoryState = { + catalogListHref: string; +}; + +export function catalogListHref(pathname?: string): string { + return dashboardPath("catalog", pathname); +} + +export function catalogDetailHref(entryKey: string, pathname?: string): string { + return dashboardPath(`catalog/${encodeURIComponent(entryKey)}`, pathname); +} + +export function catalogLocationFromPath(pathname: string): CatalogLocation { + const basePath = dashboardBasePath(pathname); + const normalizedPathname = pathname.replace(/\/+$/u, ""); + const relativePath = normalizedPathname.startsWith(basePath) + ? normalizedPathname.slice(basePath.length) + : normalizedPathname; + const segments = relativePath.replace(/^\/+|\/+$/gu, "").split("/"); + + if (segments[0] !== "catalog" || segments.length !== 2 || !segments[1]) { + return { mode: "list" }; + } + + try { + const entryKey = decodeURIComponent(segments[1]); + return isSafeEntryKey(entryKey) ? { mode: "detail", entryKey } : { mode: "list" }; + } catch { + return { mode: "list" }; + } +} + +function isSafeEntryKey(entryKey: string): boolean { + return ( + entryKey.length > 0 && + !hasControlCharacter(entryKey) && + !entryKey.split("/").some((segment) => segment === "." || segment === "..") + ); +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} diff --git a/apps/dashboard/src/components/catalog/catalog-state.test.ts b/apps/dashboard/src/components/catalog/catalog-state.test.ts new file mode 100644 index 00000000..8e1655e5 --- /dev/null +++ b/apps/dashboard/src/components/catalog/catalog-state.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { + catalogTags, + catalogStateFromLocation, + filterCatalogEntries, + defaultCatalogState, + parseCatalogState, + serializeCatalogState, + updateCatalogUrl, + type CatalogCompactEntry, +} from "./catalog-state"; + +const entries: CatalogCompactEntry[] = [ + { + entryKey: "community-low", + id: "community-low", + name: "Zulu Tool", + description: "Handles pull-requests", + tags: ["GitHub Apps"], + trustLevel: "community", + setupReadiness: "required", + installCommand: { text: "caplets install community-low", copyable: true }, + installCount: 1, + rankScore: 1, + }, + { + entryKey: "official-high", + id: "official-high", + name: "Alpha Tool", + description: "Issue tracker", + tags: ["Issues"], + trustLevel: "official", + setupReadiness: "ready", + installCommand: { text: "caplets install official-high", copyable: true }, + installCount: 50, + rankScore: 50, + }, + { + entryKey: "official-tie", + id: "official-tie", + name: "Beta Tool", + description: "GitHub automation", + tags: ["GitHub Apps"], + trustLevel: "official", + setupReadiness: "ready", + installCommand: { text: "caplets install official-tie", copyable: true }, + installCount: 50, + rankScore: 50, + }, + { + entryKey: "unknown-source", + id: "unknown-source", + name: "Opaque Tool", + description: "No obvious terms", + tags: [], + trustLevel: "official", + setupReadiness: "unknown", + installCommand: { text: "caplets add hidden-source", copyable: true }, + source: { repository: "acme/catalog-repository" }, + workflow: { label: "Release automation" }, + }, +]; + +describe("catalog discovery state", () => { + it("uses defaults when no browser location exists during SSR", () => { + expect(catalogStateFromLocation(undefined)).toEqual({ + query: "", + scope: "all", + setup: "all", + tag: "all", + sort: "rank", + }); + }); + + it("hydrates all five URL dimensions and omits defaults when serializing", () => { + const state = parseCatalogState( + new URLSearchParams("q=github&scope=official&setup=ready&tag=GitHub+Apps&sort=name"), + catalogTags(entries), + ); + expect(state).toEqual({ + query: "github", + scope: "official", + setup: "ready", + tag: "GitHub Apps", + sort: "name", + }); + expect(serializeCatalogState(state).toString()).toBe( + "q=github&scope=official&setup=ready&tag=GitHub+Apps&sort=name", + ); + expect( + serializeCatalogState( + parseCatalogState(new URLSearchParams(), catalogTags(entries)), + ).toString(), + ).toBe(""); + + const unknown = parseCatalogState(new URLSearchParams("setup=unknown")); + expect(unknown.setup).toBe("unknown"); + expect(serializeCatalogState(unknown).toString()).toBe("setup=unknown"); + }); + + it("normalizes unknown values and preserves unrelated query parameters", () => { + const state = parseCatalogState( + new URLSearchParams("scope=nope&setup=nope&tag=missing&sort=nope"), + catalogTags(entries), + ); + expect(state).toEqual({ query: "", scope: "all", setup: "all", tag: "all", sort: "rank" }); + expect(updateCatalogUrl("/dashboard/catalog?keep=1&scope=nope#results", state)).toBe( + "/dashboard/catalog?keep=1#results", + ); + }); + + it("derives stable tags and applies query, scope, setup, partial tag, rank, and name behavior", () => { + expect(catalogTags(entries)).toEqual(["GitHub Apps", "Issues"]); + expect( + filterCatalogEntries(entries, { + query: "pull requests", + scope: "all", + setup: "all", + tag: "all", + sort: "rank", + }).map((entry) => entry.entryKey), + ).toEqual(["community-low"]); + expect( + filterCatalogEntries(entries, { + query: "", + scope: "official", + setup: "ready", + tag: "github", + sort: "rank", + }).map((entry) => entry.entryKey), + ).toEqual(["official-tie"]); + expect( + filterCatalogEntries(entries, { + query: "", + scope: "all", + setup: "all", + tag: "all", + sort: "rank", + }).map((entry) => entry.name), + ).toEqual(["Alpha Tool", "Beta Tool", "Zulu Tool", "Opaque Tool"]); + expect( + filterCatalogEntries(entries, { + query: "", + scope: "all", + setup: "all", + tag: "all", + sort: "name", + }).map((entry) => entry.name), + ).toEqual(["Alpha Tool", "Beta Tool", "Opaque Tool", "Zulu Tool"]); + }); + + it("matches the public compact search corpus fields", () => { + const base = { ...defaultCatalogState, setup: "all" }; + for (const query of ["catalog repository", "release automation", "caplets add hidden source"]) { + expect( + filterCatalogEntries(entries, { ...base, query }).map((entry) => entry.entryKey), + ).toEqual(["unknown-source"]); + } + expect( + filterCatalogEntries(entries, { ...base, query: "", setup: "unknown" }).map( + (entry) => entry.entryKey, + ), + ).toEqual(["unknown-source"]); + }); +}); diff --git a/apps/dashboard/src/components/catalog/catalog-state.ts b/apps/dashboard/src/components/catalog/catalog-state.ts new file mode 100644 index 00000000..2b1ce262 --- /dev/null +++ b/apps/dashboard/src/components/catalog/catalog-state.ts @@ -0,0 +1,153 @@ +export type CatalogCompactEntry = { + entryKey: string; + id: string; + name: string; + description: string; + tags: string[]; + trustLevel: string; + setupReadiness: string; + installCommand: { + text: string; + copyable: boolean; + }; + icon?: { + type: "url" | "bundled"; + url: string; + path?: string; + }; + warnings?: Array<{ + code: string; + severity: "info" | "caution" | "danger"; + label: string; + message: string; + }>; + installCount?: number; + rankScore?: number; + installCountDisplay?: string; + authReadiness?: string; + projectBindingReadiness?: string; + source?: { repository?: string }; + workflow?: { label?: string; kind?: string }; +}; + +export type CatalogCompactResponse = { + version?: number; + view?: "compact"; + entries?: CatalogCompactEntry[]; + error?: string; +}; + +export type CatalogDiscoveryState = { + query: string; + scope: string; + setup: string; + tag: string; + sort: "rank" | "name"; +}; + +export const defaultCatalogState: CatalogDiscoveryState = { + query: "", + scope: "all", + setup: "all", + tag: "all", + sort: "rank", +}; + +const scopes: Record = { all: true, official: true, community: true }; +const setups: Record = { all: true, ready: true, required: true, unknown: true }; +const catalogKeys = ["q", "scope", "setup", "tag", "sort"] as const; + +export function catalogTags(entries: CatalogCompactEntry[]): string[] { + return [...new Set(entries.flatMap((entry) => entry.tags ?? []))].sort((a, b) => + a.localeCompare(b), + ); +} + +export function catalogStateFromLocation( + location: Pick | undefined = typeof window === "undefined" + ? undefined + : window.location, +): CatalogDiscoveryState { + return parseCatalogState(new URLSearchParams(location?.search ?? "")); +} + +export function parseCatalogState( + params: URLSearchParams, + tags: readonly string[] = [], +): CatalogDiscoveryState { + const scope = params.get("scope") ?? "all"; + const setup = params.get("setup") ?? "all"; + const requestedTag = params.get("tag") ?? "all"; + const tag = + requestedTag === "all" + ? "all" + : (tags.find((candidate) => candidate.toLowerCase() === requestedTag.toLowerCase()) ?? "all"); + return { + query: params.get("q")?.trim() ?? "", + scope: scopes[scope] ? scope : "all", + setup: setups[setup] ? setup : "all", + tag, + sort: params.get("sort") === "name" ? "name" : "rank", + }; +} + +export function serializeCatalogState(state: CatalogDiscoveryState): URLSearchParams { + const params = new URLSearchParams(); + if (state.query.trim()) params.set("q", state.query.trim()); + if (state.scope !== "all") params.set("scope", state.scope); + if (state.setup !== "all") params.set("setup", state.setup); + if (state.tag !== "all") params.set("tag", state.tag); + if (state.sort !== "rank") params.set("sort", state.sort); + return params; +} + +export function updateCatalogUrl(href: string, state: CatalogDiscoveryState): string { + const url = new URL(href, "https://dashboard.invalid"); + for (const key of catalogKeys) url.searchParams.delete(key); + for (const [key, value] of serializeCatalogState(state)) url.searchParams.set(key, value); + const query = url.searchParams.toString(); + return `${url.pathname}${query ? `?${query}` : ""}${url.hash}`; +} + +export function filterCatalogEntries( + entries: CatalogCompactEntry[], + state: CatalogDiscoveryState, +): CatalogCompactEntry[] { + const query = state.query.trim().toLowerCase(); + const normalizedQuery = normalize(query); + const tag = state.tag.trim().toLowerCase(); + const normalizedTag = normalize(tag); + return entries + .filter((entry) => { + const searchable = [ + entry.name, + entry.description, + ...(entry.tags ?? []), + entry.source?.repository ?? "", + entry.workflow?.label ?? "", + entry.installCommand.text, + ] + .join(" ") + .toLowerCase(); + return ( + (!query || searchable.includes(query) || normalize(searchable).includes(normalizedQuery)) && + (state.scope === "all" || entry.trustLevel === state.scope) && + (state.setup === "all" || entry.setupReadiness === state.setup) && + (tag === "all" || + entry.tags.some((value) => { + const lower = value.toLowerCase(); + return lower === tag || lower.includes(tag) || normalize(lower).includes(normalizedTag); + })) + ); + }) + .sort((left, right) => { + if (state.sort === "name") return left.name.localeCompare(right.name); + const rank = + (right.rankScore ?? right.installCount ?? 0) - (left.rankScore ?? left.installCount ?? 0); + return rank || left.name.localeCompare(right.name); + }); +} + +function normalize(value: string): string { + return value.replace(/[^a-z0-9]+/gu, " ").trim(); +} diff --git a/docs/plans/2026-07-11-001-feat-dashboard-catalog-parity-plan.md b/docs/plans/2026-07-11-001-feat-dashboard-catalog-parity-plan.md new file mode 100644 index 00000000..85e232c9 --- /dev/null +++ b/docs/plans/2026-07-11-001-feat-dashboard-catalog-parity-plan.md @@ -0,0 +1,491 @@ +--- +title: Dashboard Catalog Parity - Plan +type: feat +date: 2026-07-11 +topic: dashboard-catalog-parity +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +--- + +# Dashboard Catalog Parity - Plan + +## Goal Capsule + +- **Objective:** Give the built-in dashboard catalog the public catalog's complete-set browsing, virtualization, URL-backed state, and dedicated Caplet pages without copying the public site's visual treatment. +- **Product authority:** The public catalog's established behavior, the dashboard's Operator Client workflows, and the decisions confirmed in this Product Contract. +- **Open blockers:** None. The plan resolves route identity, complete-index delivery, static deep-link serving, virtualization ownership, and safe detail states. + +--- + +## Product Contract + +### Summary + +The dashboard catalog will provide behavioral parity with the public catalog inside the existing operator-console design system. Operators will browse the complete catalog through a virtualized result table, preserve discovery state in the URL, and inspect or install each Caplet from a shareable dedicated page. + +### Problem Frame + +The public catalog already treats discovery as a complete-index workflow: search, filters, sorting, counts, URL state, and virtualization all operate over the full catalog. Its result list remains bounded as the catalog grows, and each entry has a canonical inspection route. + +The dashboard currently requests at most 100 matches, derives filters from that subset, renders every returned row, and keeps detail inspection in the list page. This makes the dashboard behavior diverge from the public catalog, prevents complete discovery, increases rendering cost with result count, and makes individual Caplets impossible to bookmark or share within the operator console. + +### Key Decisions + +- **Behavioral parity, not visual cloning.** Reuse the public catalog's discovery, navigation, state, accessibility, and responsive behavior while retaining the dashboard shell, tokens, controls, and operator-focused presentation. +- **Complete index, not remote paging.** Search, filter, sort, count, and virtualization apply to the complete catalog rather than a capped or progressively loaded subset. +- **Dedicated pages, not an in-page inspector.** Each Caplet has a stable dashboard URL that owns inspection and installation; the list remains focused on discovery. +- **URL-backed list state.** Query, scope, setup, tag, and sort survive sharing and browser history. Exact pixel-level scroll restoration is not required. +- **Page scrolling.** The catalog participates in the dashboard page's normal scroll flow rather than introducing a nested result-list scroll region. + +### Actors + +- A1. **Operator:** An authenticated user browsing, inspecting, and installing Caplets on the Current Host. +- A2. **Current Host:** Serves the dashboard, retrieves catalog data, authorizes installation, and records operator actions. +- A3. **Catalog service:** Provides the complete versioned catalog index and entry content used by the dashboard. + +### Requirements + +**Complete discovery and URL state** + +- R1. The dashboard must make the complete catalog available to search, filters, sorting, counts, and tag choices without a user-visible result ceiling. +- R2. Query, source scope, setup readiness, tag, and sort must be represented in the catalog URL and restored on direct load, Back, and Forward navigation. +- R3. Search and every filter or sort change must apply to the complete catalog and update the visible match count. +- R4. Changing search, filters, or sort must reset results to the beginning while preserving focus on the control that initiated the change. +- R5. Resetting filters must clear query and filters, restore the default ranking, update the URL, and return focus to search. + +**Virtualized result browsing** + +- R6. The result surface must virtualize rows with bounded overscan so the rendered DOM remains bounded when the catalog contains at least 10,000 entries. +- R7. Virtualization must use page scrolling and must not introduce a nested vertical result-list scrollbar. +- R8. Result rows must retain stable identity and predictable responsive height estimates across desktop, tablet, mobile, and narrow-mobile layouts. +- R9. Each row must expose the Caplet name, trust signal, concise description, install count, readiness or safety signals, and an available installation action. +- R10. Activating non-action row space must navigate to that Caplet's detail page, while explicit links, installation actions, and modified clicks retain their normal behavior. +- R11. The virtualized list must preserve semantic row structure, accessible position and count information, keyboard access, and focus for rendered controls. + +**Dedicated Caplet pages** + +- R12. Every catalog entry must have a stable, shareable dashboard route based on its catalog identity rather than only its display name. +- R13. A Caplet page must provide a Catalog breadcrumb or equivalent return path and entry-specific page metadata. +- R14. A Caplet page must show trust and repository identity, icon, name, description, install readiness, warnings, readable CAPLET.md content, and the authenticated installation action. +- R15. A Caplet page must expose source path, workflow, install count, revision, content hash, auth readiness, setup readiness, Project Binding readiness, and required or optional setup actions when available. +- R16. Unreadable content must have a clear unavailable state and must not present installation as safe or available when the required inspection contract cannot be satisfied. +- R17. A missing, suppressed, or no-longer-indexed entry must produce a dedicated unavailable page with a route back to catalog search. + +**Operator workflow and presentation** + +- R18. Installation from either the result list or detail page must retain the dashboard's existing confirmation, authorization, activity logging, readiness review, and error handling. +- R19. The feature must use the dashboard's existing design system and operator-console navigation rather than copying the public catalog's branding or page chrome. +- R20. Loading, success, empty, failure, copy, and installation states must be communicated through accessible text or announcements rather than color alone. +- R21. Motion used for state changes must respect reduced-motion preferences, and no content may depend on an entrance animation to become visible. + +### Catalog Navigation Flow + +```mermaid +flowchart TB + A[Open dashboard catalog URL] --> B[Restore query filters and sort] + B --> C[Search and filter complete catalog index] + C --> D[Render bounded virtual rows in page scroll] + D --> E{Operator action} + E -->|Open row| F[Dedicated Caplet page] + E -->|Install action| G[Dashboard confirmation and install] + F -->|Install| G + F -->|Back or Catalog breadcrumb| H[Catalog URL restores discovery state] + C -->|No matches| I[Empty state with reset action] + F -->|Entry unavailable| J[Unavailable page with return path] +``` + +### Key Flows + +- F1. **Browse and refine the complete catalog** + - **Trigger:** A1 opens the dashboard catalog directly or returns through browser history. + - **Actors:** A1, A2, A3 + - **Steps:** The page restores URL state, obtains the complete index, applies discovery controls, and renders only the current virtual window. + - **Outcome:** A1 can inspect the full match set without a capped result count or unbounded DOM. + - **Covered by:** R1-R11, R20-R21 + +- F2. **Open and share a Caplet** + - **Trigger:** A1 opens a result row, follows its link, or visits a shared detail URL. + - **Actors:** A1, A2, A3 + - **Steps:** The dashboard resolves the catalog identity and presents the entry's inspection, readiness, metadata, and installation surface. + - **Outcome:** The Caplet is independently addressable and inspectable without list-page selection state. + - **Covered by:** R10, R12-R17, R19-R20 + +- F3. **Return to discovery** + - **Trigger:** A1 uses Back, Forward, or the Catalog return path from a detail page. + - **Actors:** A1, A2 + - **Steps:** The catalog route restores query, filters, and sort from the URL and renders the corresponding result set. + - **Outcome:** A1 resumes the same discovery context without exact pixel-level scroll restoration being required. + - **Covered by:** R2-R5, R13 + +- F4. **Install from discovery or detail** + - **Trigger:** A1 invokes an available installation action. + - **Actors:** A1, A2 + - **Steps:** The dashboard presents its existing review and typed confirmation, authorizes the operation, installs on the Current Host, and reports the result. + - **Outcome:** Catalog parity does not weaken operator safety or bypass existing administrative controls. + - **Covered by:** R9, R14, R16, R18, R20 + +### Acceptance Examples + +- AE1. **Complete-set discovery** + - **Covers R1, R3, R6.** + - **Given:** The catalog contains more than 100 entries and includes a match beyond the first 100. + - **When:** A1 searches for that match. + - **Then:** The match is discoverable, the count reflects all matches, and only a bounded virtual window is mounted. + +- AE2. **Direct URL restoration** + - **Covers R2-R5.** + - **Given:** A catalog URL contains non-default query, scope, setup, tag, and sort values. + - **When:** A1 opens the URL directly. + - **Then:** Every control and the result set reflect those values; resetting returns to default ranking and focuses search. + +- AE3. **Browser history across detail navigation** + - **Covers R2, R10, R12-R13.** + - **Given:** A1 filtered the catalog and opened a Caplet detail page. + - **When:** A1 uses Back. + - **Then:** The catalog restores the prior query, filters, and sort without requiring exact pixel-level scroll restoration. + +- AE4. **Virtualized responsive browsing** + - **Covers R6-R8, R11, R21.** + - **Given:** The catalog contains 10,000 entries. + - **When:** A1 scrolls at desktop, tablet, mobile, and narrow-mobile widths. + - **Then:** DOM row count stays bounded, row positions remain stable, controls remain keyboard accessible, and reduced-motion settings are respected. + +- AE5. **Empty result recovery** + - **Covers R3-R5, R20.** + - **Given:** The active discovery state matches no entries. + - **When:** The result set updates. + - **Then:** The dashboard announces zero matches, shows a reset action, and returns focus to search after reset. + +- AE6. **Unavailable entry** + - **Covers R16-R17.** + - **Given:** A shared detail URL resolves to an absent entry or an entry without readable content. + - **When:** A1 opens it. + - **Then:** The page explains the unavailable state, provides a catalog return path, and does not expose an unsafe installation action. + +- AE7. **Safe installation** + - **Covers R14, R18, R20.** + - **Given:** A1 chooses Install from a result row or detail page. + - **When:** The operation requires confirmation or reports a readiness warning. + - **Then:** The existing dashboard review, authorization, activity logging, success feedback, and safe error handling remain in effect. + +### Success Criteria + +- The dashboard can discover and count entries beyond the former 100-result ceiling. +- A 10,000-entry catalog keeps a bounded number of result rows mounted throughout scrolling and filtering. +- Catalog URLs reproduce query, scope, setup, tag, and sort state on direct load and browser navigation. +- Every result links to an independently loadable dashboard detail page with the full inspection and installation contract. +- Search, filter, sort, reset, row navigation, copy, and installation flows remain keyboard operable and produce accessible state feedback. +- Desktop, tablet, mobile, and narrow-mobile layouts remain usable without nested vertical result scrolling. + +### Scope Boundaries + +- Visual cloning of the public catalog's branding, header, or page chrome is out of scope. +- Server-side paging, infinite loading, and loading additional results during scroll are out of scope. +- Exact pixel-level scroll restoration after returning from detail is not required; restoring URL-backed discovery state is required. +- Changes to the public catalog's behavior or visual design are out of scope unless needed to preserve a shared behavioral contract. +- Project-scoped installation remains out of scope; the dashboard continues to administer the Current Host's global catalog lifecycle. + +### Dependencies / Assumptions + +- The versioned catalog service continues to provide a complete index with stable entry identities and the metadata needed for list and detail presentation. +- The dashboard remains an authenticated Current Host surface with existing installation authorization, confirmation, activity logging, and safe-error behavior. +- Catalog identities remain stable enough to support durable detail links; exact route encoding is a planning decision. +- The existing dashboard detail contract remains the authority for operator-only readiness and setup actions that the public catalog does not own. + +### Sources / Research + +- `apps/catalog/src/scripts/virtual-results.ts` — public complete-index URL state, page-scroll virtualization, row navigation, focus, and responsive estimates. +- `apps/catalog/src/components/ResultList.astro` — public result semantics, bounded initial markup, status signals, install actions, and resettable empty state. +- `apps/catalog/src/pages/caplets/[entryKey].astro` and `apps/catalog/src/components/CapletDetail.astro` — public canonical detail route and inspection surface. +- `apps/dashboard/src/components/DashboardApp.tsx` — current dashboard result cap, local state, full-row rendering, in-page detail, installation workflow, and operator readiness data. +- `packages/core/src/current-host/operations.ts` and `packages/core/src/catalog/types.ts` — dashboard catalog operation and entry contracts. +- `docs/brainstorms/2026-06-27-catalog-search-virtualized-results-requirements.md` — established complete-set, 10,000-entry, virtualization, URL-state, responsive, and accessibility requirements. + +--- + +## Planning Contract + +**Product Contract preservation:** No R/A/F/AE scope changed. The planning-owned questions were resolved into the decisions and units below. + +### Key Technical Decisions + +- KTD1. **Expose a compact complete-index projection through the catalog API.** Extend the existing versioned list endpoint with a compact-index view rather than sending every entry's CAPLET.md body to the dashboard. Define the projection in `@caplets/core` so the public catalog producer and Current Host consumer share entry identity, ranking, readiness, warning, icon, and install-command semantics. +- KTD2. **Use `entryKey` as the canonical dashboard route and catalog-action identity.** Build base-path-safe detail URLs with encoded catalog entry keys, decode defensively, and resolve detail through the existing catalog entry API. Server-side catalog installation accepts the stable `entryKey`, resolves the current detail, and derives the display ID/source command rather than trusting client-supplied summary fields. +- KTD3. **Keep catalog acquisition behind Current Host operations with hard resource bounds.** Add a complete-index operation and evolve detail lookup to stable entry identity; the dashboard browser continues to call only its own authenticated host. The host validates downstream envelopes, applies a five-second timeout, rejects compact responses over 32 MiB or 10,000 entries and detail responses over 2 MiB, bounds strings/URLs/tags before retention, and classifies every limit violation as `DOWNSTREAM_PROTOCOL_ERROR`. +- KTD4. **Serve deep links through the static dashboard shell.** The dashboard remains a static Astro build. Current Host static routing falls back from `/dashboard/catalog/` to the emitted catalog shell, after which React selects list or detail mode from the browser location. +- KTD5. **Mirror the public URL-state policy.** One pure codec owns `q`, `scope`, `setup`, `tag`, and `sort`. Control changes use `replaceState`, popstate replays without rewriting, defaults are omitted, malformed values normalize, and list-to-detail navigation uses normal history so Back restores the encoded discovery state. +- KTD6. **Reuse TanStack virtual-core in the dashboard browser layer.** Add `@tanstack/virtual-core` directly to the dashboard workspace and adapt the public catalog's window virtualizer, overscan, stable keys, responsive estimates, focus behavior, and page-scroll model to React. Do not add a second vertical scrolling region or render parallel mobile and desktop result trees. +- KTD7. **Make detail safety an authoritative state machine.** Detail is `loading`, `available`, `unavailable`, or `failed`. Only `available` detail with readable content and an installable command enables confirmation. A row Install action first fetches current detail by `entryKey` in place; failure leaves the action non-installable. The authenticated install endpoint independently resolves the same current detail and rejects absent, suppressed, unreadable, stale, summary-only, or command-mismatched requests, so direct API calls cannot bypass UI gating. +- KTD8. **Extract a dashboard catalog feature module.** Keep `DashboardApp` as the shell/router and move discovery state, route helpers, virtual rows, and detail presentation into focused catalog files. Reuse the existing confirmation/action callback so Operator authorization, activity logging, lockfile behavior, and safe errors remain centralized. +- KTD9. **Keep downstream CAPLET.md inert.** Present CAPLET.md with the dashboard's existing escaped plain-text `
` treatment; do not interpret raw HTML or construct links from catalog Markdown. Repository links remain separately validated `https:` URLs and any new-tab link carries `rel="noopener noreferrer"`.
+
+### High-Level Technical Design
+
+```mermaid
+flowchart TB
+  S[catalog.caplets.dev API] -->|compact complete index| H[Current Host catalog operations]
+  S -->|entryKey detail| H
+  H --> A[Authenticated dashboard API]
+  A --> C[Catalog feature state and URL codec]
+  C --> V[Window virtualized result surface]
+  C --> D[Dedicated detail mode]
+  V -->|push detail URL| D
+  D -->|Back or breadcrumb| C
+  V --> I[Shared safe install action]
+  D --> I
+  R[Current Host static route fallback] --> C
+  R --> D
+```
+
+The complete index is fetched once for a mounted catalog feature and retained while list and detail modes change. Filtering, sorting, tag derivation, counts, and ranking are pure client operations over that snapshot. Detail is fetched independently by `entryKey`, so direct links do not depend on list selection or list-summary content.
+
+List-to-detail, direct-load, and Forward transitions focus the detail page heading (`tabIndex={-1}`) after the requested entry resolves. Back or breadcrumb return restores focus to the originating row-title link when mounted; otherwise it focuses the catalog heading or search control without forcing exact scroll restoration.
+
+### Output Structure
+
+```text
+apps/dashboard/src/components/catalog/
+  CatalogPage.tsx
+  CatalogResults.tsx
+  CatalogDetailPage.tsx
+  catalog-state.ts
+  catalog-route.ts
+```
+
+Exact filenames may consolidate when one file would remain shallow, but state/route logic must stay independently testable and `DashboardApp.tsx` must not retain the full catalog implementation.
+
+### Sequencing
+
+```mermaid
+flowchart LR
+  U1[U1 Shared catalog contracts] --> U2[U2 Dashboard deep-link routing]
+  U1 --> U3[U3 Complete-index state]
+  U2 --> U3
+  U3 --> U4[U4 Virtualized results]
+  U1 --> U5[U5 Dedicated safe detail]
+  U2 --> U5
+  U3 --> U5
+  U4 --> U6[U6 Integrated catalog QA]
+  U5 --> U6
+```
+
+### System-Wide Impact
+
+- **Catalog API:** Adds a compact complete-index representation while preserving the existing full-list and per-entry contracts for current consumers.
+- **Current Host:** Adds index retrieval and stable-entry detail selection without moving catalog policy into the HTTP adapter.
+- **Dashboard static serving:** Introduces one narrow catalog deep-link fallback; other dashboard routes and asset traversal protections remain unchanged.
+- **Dashboard browser:** Replaces capped remote search and in-page selection with one complete snapshot, URL state, window virtualization, and route-level detail.
+- **Package surface:** Adds the already-used `@tanstack/virtual-core` dependency to `apps/dashboard`; published runtime package behavior changes because the built-in dashboard is bundled by `@caplets/core`.
+
+### Risks and Mitigations
+
+- **Oversized catalog payload:** A full `CatalogEntry[]` includes readable content and does not scale to 10,000 entries. Mitigate with KTD1's compact projection and fetch content only for detail.
+- **Static deep-link 404:** Client routing alone cannot satisfy a direct nested URL. Mitigate with KTD4 and focused static-route tests under root and configured base paths.
+- **Route collisions or malformed keys:** Display IDs are not globally stable. Mitigate with encoded `entryKey`, defensive decoding, and explicit unavailable behavior.
+- **Stale async detail:** Rapid navigation can resolve requests out of order. Abort or correlate detail loads so an older response cannot overwrite the active URL.
+- **Virtualizer offset drift:** The sticky dashboard shell and responsive row content can skew window measurements. Use bounded row content, document-offset measurement, resize remeasurement, and browser QA at all four target widths.
+- **Accessibility regression from virtualization:** Removed DOM nodes can disrupt focus and semantics. Keep real links/actions, role-based row semantics, full `aria-rowcount`, correct `aria-rowindex`, active-control focus, and reduced-motion behavior.
+- **Safety regression from summary or direct-API fallback:** Never treat a list projection or client-side disabled control as sufficient installation evidence. Enforce KTD7 unconditionally in both UI preflight and the authenticated server-side catalog install operation.
+
+### Assumptions
+
+- The public catalog API can add a compact representation without changing public catalog page behavior.
+- The catalog's existing `entryKey` remains the source-stable identity for both official and community entries, including Multi-Backend Caplet File parents.
+- Exact responsive estimates begin with the public catalog's 72/168/188/320 pixel values and may be tuned during browser verification without changing the Product Contract.
+- Returning near the previously opened row is best-effort; URL-backed discovery state is the required restoration contract.
+
+### Sources and Patterns
+
+- `apps/catalog/src/scripts/virtual-results.ts` and `apps/catalog/test/virtual-results.test.ts` are the behavioral oracle for complete-index filtering, URL state, page-scroll virtualization, responsive estimates, focus, click routing, and cleanup.
+- `apps/catalog/src/lib/search-row.ts`, `apps/catalog/src/lib/catalog-store.ts`, and `apps/catalog/src/pages/api/v1/catalog/index.ts` define the current public index and count/rank enrichment.
+- `apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts` is the stable detail lookup contract.
+- `apps/dashboard/src/lib/paths.ts` is the required base-path-safe URL primitive.
+- `apps/dashboard/src/components/DashboardApp.test.tsx` supplies the React/happy-dom harness; new catalog tests should mirror its API mocking and history cleanup.
+- `packages/core/src/current-host/catalog-operations.ts` is the deep Current Host catalog seam; `packages/core/src/serve/http.ts` remains an adapter.
+- `packages/core/test/dashboard-catalog.test.ts` and `packages/core/test/dashboard-static.test.ts` own the host API and static-serving contracts.
+
+---
+
+## Implementation Units
+
+### U1. Shared compact index and stable detail contracts
+
+- **Goal:** Deliver a complete, compact catalog index and stable-entry detail lookup through the public catalog API, Current Host operations, and dashboard HTTP adapter.
+- **Requirements:** R1-R3, R9, R12, R14-R17; F1-F2; AE1, AE6.
+- **Dependencies:** None.
+- **Files:**
+  - Modify `packages/core/src/catalog/types.ts`
+  - Modify the relevant exports under `packages/core/src/catalog/`
+  - Modify `apps/catalog/src/lib/catalog-store.ts`
+  - Modify `apps/catalog/src/pages/api/v1/catalog/index.ts`
+  - Modify `apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts` only if response classification needs strengthening
+  - Modify `apps/catalog/test/catalog-api.test.ts`
+  - Modify `packages/core/src/current-host/operations.ts`
+  - Modify `packages/core/src/current-host/catalog.ts`
+  - Modify `packages/core/src/current-host/catalog-operations.ts`
+  - Modify `packages/core/src/serve/http.ts`
+  - Modify `packages/core/test/dashboard-catalog.test.ts`
+- **Approach:** Define a shared compact index entry that excludes readable content but retains stable identity, rank/count, display, trust, readiness, warning, icon, and install-command fields. Add a compact view to the existing versioned list endpoint. Add Current Host complete-index and stable-key catalog-install operations; make official detail resolve through the per-entry API by encoded `entryKey`, while local-source helpers may project local entries through the same identity contract. Read response bodies through bounded streaming helpers before JSON parsing, validate collection/field limits and envelopes, and preserve timeout/safe-error behavior at the Current Host boundary. On install, authorize the Operator, re-resolve current detail by `entryKey`, derive the actual source/display ID, compare the current command with any confirmed command identity, and reject every unavailable, unreadable, stale, or summary-only target before invoking the existing installer.
+- **Execution note:** Start with failing API and Current Host tests proving the former 100-entry ceiling and ID-only detail lookup are insufficient.
+- **Patterns to follow:** `apps/catalog/src/lib/search-row.ts`, `apps/catalog/src/lib/catalog-store.ts`, `packages/core/src/current-host/catalog-operations.ts`, and the envelope validation in `packages/core/src/current-host/catalog.ts`.
+- **Test scenarios:**
+  - Covers AE1. A compact index response with 150 entries returns all 150 through the dashboard API, including an entry beyond position 100.
+  - Compact index entries omit `contentMarkdown` while retaining `entryKey`, count/rank, tags, readiness, warnings, icon, source identity, and install command.
+  - A detail request by an encoded repository-qualified `entryKey` returns the matching entry even when another entry shares its display ID.
+  - Missing detail is machine-distinguishable from a timeout, non-success response, or malformed downstream envelope.
+  - Official downstream timeout and invalid-response behavior retain `SERVER_UNAVAILABLE` and `DOWNSTREAM_PROTOCOL_ERROR` safe errors.
+  - Oversized, over-count, over-cardinality, and over-length compact/detail responses are rejected as `DOWNSTREAM_PROTOCOL_ERROR` without retaining partial attacker-controlled data.
+  - Direct authenticated install requests cannot bypass detail readability/installability checks or substitute a stale command, source, or display ID.
+  - Existing local-source detail/setup-action tests and global install/activity tests remain green.
+- **Verification:** The public API and dashboard host expose complete compact index data, stable-entry detail, and unchanged installation side effects without content leakage into the index.
+
+### U2. Base-path-safe catalog routing and static deep-link fallback
+
+- **Goal:** Make list and detail URLs directly loadable, refreshable, shareable, and compatible with configured dashboard base paths.
+- **Requirements:** R2, R10, R12-R13, R17, R19; F2-F3; AE3, AE6.
+- **Dependencies:** U1.
+- **Files:**
+  - Create `apps/dashboard/src/components/catalog/catalog-route.ts`
+  - Modify `apps/dashboard/src/lib/paths.ts`
+  - Modify `apps/dashboard/src/components/DashboardApp.tsx`
+  - Modify `apps/dashboard/src/components/DashboardApp.test.tsx`
+  - Modify `packages/core/src/dashboard/routes.ts`
+  - Modify `packages/core/test/dashboard-static.test.ts`
+- **Approach:** Extend dashboard location parsing so `catalog` remains the shell RouteKey while an optional decoded `entryKey` selects detail mode. Generate list and detail hrefs through `dashboardPath`. Preserve the originating list URL in history state for the detail breadcrumb, with the plain catalog route as fallback. In static serving, map only nested catalog routes without file extensions to the existing catalog shell after normal decode/traversal safety checks.
+- **Execution note:** Characterize current dashboard route/base-path behavior before adding the nested catalog case.
+- **Patterns to follow:** `apps/dashboard/src/lib/paths.ts`, `DashboardApp`'s existing `navigate`/popstate handling, public `encodeURIComponent(entry.entryKey)` links, and `dashboardStaticResponse` path safety.
+- **Test scenarios:**
+  - Direct `/dashboard/catalog/` and the equivalent configured-base-path URL serve the catalog shell and load detail mode.
+  - Malformed encoding and traversal-shaped paths do not escape the static directory or resolve as catalog detail.
+  - List and detail URL helpers round-trip entry keys containing colons and encoded source-path characters.
+  - Normal row navigation pushes detail history; Back restores the prior list URL; Forward restores detail.
+  - List-to-detail, direct-load, and Forward transitions focus the resolved detail heading; Back/breadcrumb return focuses the originating mounted row link or the catalog heading/search fallback.
+  - A direct/shared detail load uses `/dashboard/catalog` as its return path when no originating list state exists.
+- **Verification:** Nested catalog URLs work on initial request, refresh, Back, Forward, and non-root mounts without broadening the static fallback.
+
+### U3. Complete-index state and URL-backed discovery
+
+- **Goal:** Replace capped remote query state with one complete index snapshot and deterministic local discovery controls.
+- **Requirements:** R1-R5, R20-R21; F1, F3; AE1-AE3, AE5.
+- **Dependencies:** U1, U2.
+- **Files:**
+  - Create `apps/dashboard/src/components/catalog/catalog-state.ts`
+  - Create `apps/dashboard/src/components/catalog/CatalogPage.tsx`
+  - Create `apps/dashboard/src/components/catalog/catalog-state.test.ts`
+  - Create `apps/dashboard/src/components/catalog/CatalogPage.test.tsx`
+  - Modify `apps/dashboard/src/components/DashboardApp.tsx`
+- **Approach:** Fetch the complete index once per mounted feature, retain the last complete snapshot through list/detail transitions, and derive tags, rank/name sorting, filters, and counts from pure functions. Initialize the five discovery dimensions from the URL, normalize unknown values, omit defaults when serializing, use `replaceState` for control changes, and replay popstate without rewrite loops. Reset virtual position on refinements while preserving the active control; Reset restores defaults and focuses search.
+- **Patterns to follow:** `apps/catalog/src/lib/search-filter.ts`, `apps/catalog/src/scripts/virtual-results.ts`, dashboard API error handling, and the existing dashboard React test harness.
+- **Test scenarios:**
+  - Covers AE1. Search finds an entry beyond the former first 100 and the match count reflects the complete snapshot.
+  - Covers AE2. Direct URL values hydrate query, scope, setup, tag, and sort; unknown values normalize to defaults.
+  - Scope, setup, tag, name sort, and rank sort operate over the full index; tags derive from the full snapshot rather than visible virtual rows.
+  - Control changes update URL state without adding history spam, preserve unrelated query parameters, reset results to the beginning, and retain initiating-control focus.
+  - Covers AE5. Zero matches announce the result and expose Reset; Reset restores defaults, URL, count, and search focus.
+  - First-load failure exposes Retry; superseded requests cannot overwrite a newer successful snapshot.
+- **Verification:** Discovery behavior is deterministic from index plus URL, no request includes a search query or 100-entry limit, and all five dimensions survive direct load and browser history.
+
+### U4. Window-virtualized responsive result surface
+
+- **Goal:** Render a bounded, accessible result window in page scroll while preserving public-catalog row behavior and dashboard presentation.
+- **Requirements:** R6-R11, R19-R21; F1; AE1, AE4-AE5.
+- **Dependencies:** U3.
+- **Files:**
+  - Modify `apps/dashboard/package.json`
+  - Modify `pnpm-lock.yaml`
+  - Create `apps/dashboard/src/components/catalog/CatalogResults.tsx`
+  - Create `apps/dashboard/src/components/catalog/CatalogResults.test.tsx`
+  - Modify `apps/dashboard/src/components/catalog/CatalogPage.tsx`
+  - Remove superseded result-grid/loading helpers from `apps/dashboard/src/components/DashboardApp.tsx`
+- **Approach:** Add the same `@tanstack/virtual-core` version used by the public catalog. Use a window virtualizer with overscan 8, stable `entryKey` keys, list document offset, resize remeasurement, and public responsive estimates as initial values. Render one responsive row tree with role-based table semantics, real title links, isolated Install and Copy controls, full `aria-rowcount`, correct `aria-rowindex`, bounded descriptions/statuses, and no nested vertical scroller. Copy uses the existing accessible clipboard success toast; rejection announces failure and exposes the command for manual selection. Row Install fetches current detail by `entryKey` in place, then enters the shared confirmation only when KTD7 is satisfied.
+- **Execution note:** Prove bounded rendering with 10,000 deterministic fixtures before tuning visual estimates.
+- **Patterns to follow:** `apps/catalog/src/scripts/virtual-results.ts`, `apps/catalog/src/components/ResultList.astro`, and `apps/catalog/test/virtual-results.test.ts`.
+- **Test scenarios:**
+  - Covers AE4. A 10,000-entry index reports 10,000 matches while mounted result rows remain bounded during initial render, scrolling, filtering, and sorting.
+  - Page scroll drives the virtualizer; the result surface has no nested vertical overflow region.
+  - Stable row keys preserve the correct entries after rank/name sort and filter changes.
+  - Desktop, tablet, mobile, and narrow-mobile estimates switch at the defined breakpoints and resize triggers remeasurement.
+  - An unmodified primary click on non-interactive row space navigates; title links, Install/Copy controls, and modified clicks keep native behavior.
+  - Rows expose semantic row structure, `aria-rowindex`, total `aria-rowcount`, keyboard-accessible controls, accessible count/copy/install changes, and reduced-motion-safe scrolling; clipboard rejection leaves a manually selectable command.
+  - Unmount removes window scroll, resize, media-query, and virtualizer subscriptions.
+- **Verification:** Large result sets keep bounded DOM and correct semantics across all target widths without duplicating desktop/mobile trees.
+
+### U5. Dedicated detail page and shared safe installation
+
+- **Goal:** Replace the in-page inspector with a route-level Caplet page that preserves full operator inspection and installation safety.
+- **Requirements:** R12-R20; F2, F4; AE3, AE6-AE7.
+- **Dependencies:** U1, U2, U3.
+- **Files:**
+  - Create `apps/dashboard/src/components/catalog/CatalogDetailPage.tsx`
+  - Create `apps/dashboard/src/components/catalog/CatalogDetailPage.test.tsx`
+  - Modify `apps/dashboard/src/components/catalog/CatalogPage.tsx`
+  - Modify `apps/dashboard/src/components/DashboardApp.tsx`
+  - Modify `packages/core/test/dashboard-catalog.test.ts` if server-side install-readiness enforcement changes
+- **Approach:** Drive detail from route identity and explicit request state, not list selection. Extract and reuse the existing warning, escaped plain-text CAPLET.md, readiness, setup-action, metadata, Copy, confirmation, and installation presentation. Abort or correlate navigation requests. Render distinct unavailable and transient-failure states with return/retry actions. Focus the resolved detail heading after list-to-detail, direct-load, and Forward transitions; restore the originating mounted row link or catalog heading/search fallback on return. Keep one install function for row and detail entry points: row invocation first fetches current detail in place, and both paths remain disabled until detail is available, readable, installable, and accepted by the server-side KTD7 validation.
+- **Execution note:** Add state-transition tests before deleting the in-page fallback, because stale detail and unsafe summary fallback are plausible regressions.
+- **Patterns to follow:** Existing `CatalogDetailPanel`, `SafetyWarnings`, `installReviewSummary`, `useActionConfirm`, public `CapletDetail.astro`, and Current Host activity tests.
+- **Test scenarios:**
+  - Direct encoded detail route loads by `entryKey`, renders entry-specific title/context, and does not require an index selection.
+  - Available detail shows trust/repository, warnings, inert escaped CAPLET.md, workflow, counts, revision/hash, three readiness states, setup actions, copy feedback, and install review; only validated `https:` repository links are clickable.
+  - Covers AE6. Missing/suppressed, unreadable, malformed-key, timeout, and server-failure states remain distinct; unsafe states expose no enabled install.
+  - Rapid A-to-B navigation cannot display A's late response under B's URL.
+  - Covers AE7. Row installation fetches and validates current detail before shared typed confirmation; row and detail paths call the same stable-key endpoint, disable duplicate submission, refresh host state, and announce success or safe failure.
+  - Copy succeeds with an accessible toast; clipboard rejection announces failure and leaves the command manually selectable on list and detail surfaces.
+  - Detail entry focuses its heading; breadcrumb/Back focuses the originating mounted row link or the documented catalog fallback and returns to the originating filtered list URL or plain catalog route.
+- **Verification:** Every Caplet route is independently inspectable, safe failures never fall back to summary installation, and existing Operator lifecycle behavior remains intact.
+
+### U6. Integrated catalog parity verification
+
+- **Goal:** Prove the end-to-end dashboard behavior in the bundled Current Host surface and remove superseded catalog code.
+- **Requirements:** R1-R21; F1-F4; AE1-AE7.
+- **Dependencies:** U4, U5.
+- **Files:**
+  - Modify `apps/dashboard/src/components/DashboardApp.test.tsx` only for shell-level integration cases
+  - Modify `packages/core/test/dashboard-catalog.test.ts`
+  - Modify `packages/core/test/dashboard-static.test.ts`
+  - Modify `.changeset/fix-dashboard-catalog-source.md`
+  - Remove obsolete catalog list/detail helpers from `apps/dashboard/src/components/DashboardApp.tsx`
+- **Approach:** Exercise the built dashboard through Current Host authentication with representative official/community, Multi-Backend, warning, unreadable, missing, and 10,000-entry fixtures. Verify browser history, responsive page-scroll virtualization, focus, reduced motion, detail safety, and installation. Keep the public catalog suite as a parity oracle rather than moving dashboard behavior into it.
+- **Execution note:** Browser verification is required; happy-dom tests cannot establish real window measurement, focus, overflow, or responsive layout.
+- **Patterns to follow:** `packages/core/test/dashboard-catalog.test.ts`, `packages/core/test/dashboard-static.test.ts`, `apps/catalog/test/virtual-results.test.ts`, and the `ce-test-browser` affected-page workflow.
+- **Test scenarios:**
+  - The bundled dashboard authenticates, loads all matches, navigates to a detail deep link, refreshes it, installs after confirmation, and returns to preserved list state.
+  - A 10,000-entry fixture keeps mounted rows bounded during real scrolling at desktop, tablet, mobile, and narrow-mobile widths.
+  - No nested vertical result scrollbar appears; horizontal overflow remains limited to layouts that require it.
+  - Keyboard search/filter/reset/row/detail/copy/install flows move or retain visible focus at the documented destination and produce accessible announcements, including clipboard rejection.
+  - Reduced-motion mode avoids smooth scrolling or transition-dependent visibility.
+  - Missing and unreadable detail routes remain non-installable after refresh and Back/Forward.
+- **Verification:** Focused API/component/static suites pass, browser QA covers the affected routes and widths, the built core package contains the new dashboard assets, and no obsolete in-page inspector or capped query path remains.
+
+---
+
+## Verification Contract
+
+| Gate                       | Scope                                                                                                    | Required result                                                                                                                                   |
+| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Catalog API tests          | `pnpm --filter @caplets/catalog test -- test/catalog-api.test.ts`                                        | Compact/full API views and stable detail outcomes pass.                                                                                           |
+| Current Host catalog tests | `pnpm --filter @caplets/core test -- test/dashboard-catalog.test.ts test/dashboard-static.test.ts`       | Complete index, detail identity, install safety, activity, and deep-link serving pass.                                                            |
+| Dashboard component tests  | `pnpm test -- apps/dashboard/src/components/catalog apps/dashboard/src/components/DashboardApp.test.tsx` | URL state, virtualization, detail states, history, accessibility, and shared install behavior pass.                                               |
+| Public parity oracle       | `pnpm --filter @caplets/catalog test -- test/virtual-results.test.ts`                                    | Existing public behavior remains green.                                                                                                           |
+| Dashboard typecheck/build  | `pnpm --filter @caplets/dashboard typecheck` and `pnpm --filter @caplets/dashboard build`                | React/Astro types pass and every dashboard static route builds.                                                                                   |
+| Core package build         | `pnpm --filter @caplets/core build`                                                                      | Updated dashboard assets are copied into the built-in runtime package.                                                                            |
+| Browser verification       | Run `ce-test-browser` for `/dashboard/catalog` and one encoded detail route                              | Real page scrolling, bounded rows, Back/Forward, refresh, focus, responsive widths, reduced motion, and no nested vertical overflow are observed. |
+| Final repository gate      | `pnpm verify`                                                                                            | Format, lint, generated checks, types, all tests, benchmarks, and build pass.                                                                     |
+
+---
+
+## Definition of Done
+
+- U1-U6 are implemented in dependency order and every cited R/F/AE is either verified or explicitly blocked by evidence that changes the Product Contract.
+- The dashboard browses the complete compact index; no browser request or host response imposes the former 100-result ceiling.
+- A 10,000-entry result set keeps mounted rows bounded and uses page scrolling at all four responsive targets.
+- Query, scope, setup, tag, and sort round-trip through direct URLs and Back/Forward without rewrite loops.
+- Every catalog entry has a refreshable, base-path-safe dashboard detail route keyed by `entryKey`.
+- Missing, unreadable, malformed, timed-out, and failed detail states remain distinguishable and non-installable.
+- Row and detail installation preserve Operator authorization, confirmation, activity logging, readiness review, duplicate-submit protection, and safe feedback.
+- Keyboard, focus, ARIA count/index, empty reset, responsive, and reduced-motion contracts pass component and browser verification.
+- Public catalog API and virtualization tests remain green; the dashboard retains its own visual language.
+- The changeset describes catalog API sourcing, behavioral parity, dedicated routes, virtualization, and test-state isolation.
+- `pnpm verify` passes, abandoned experiments and superseded in-page catalog code are removed, and the final diff contains no compatibility shim for the capped query path.
diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts
index a21ad3d2..9434d4d6 100644
--- a/packages/core/src/catalog/index.ts
+++ b/packages/core/src/catalog/index.ts
@@ -32,6 +32,8 @@ export {
 export { catalogWarningsForEntry } from "./warnings";
 export type {
   CatalogEntry,
+  CatalogCompactEntry,
+  CatalogCompactIndexEnvelope,
   CatalogEntryChild,
   CatalogEntryInput,
   CatalogEntryKey,
diff --git a/packages/core/src/catalog/types.ts b/packages/core/src/catalog/types.ts
index bd9c7895..7060ba22 100644
--- a/packages/core/src/catalog/types.ts
+++ b/packages/core/src/catalog/types.ts
@@ -142,3 +142,16 @@ export type CatalogEntry = {
   installCommand: CatalogInstallCommand;
   warnings: CatalogWarning[];
 };
+
+/** Complete-index projection. Readable CAPLET.md content is intentionally excluded. */
+export type CatalogCompactEntry = Omit & {
+  installCount: number;
+  installCountDisplay: string;
+  rankScore: number;
+};
+
+export type CatalogCompactIndexEnvelope = {
+  version: 1;
+  view: "compact";
+  entries: CatalogCompactEntry[];
+};
diff --git a/packages/core/src/current-host/catalog-operations.ts b/packages/core/src/current-host/catalog-operations.ts
index b24ed304..02b1eaca 100644
--- a/packages/core/src/current-host/catalog-operations.ts
+++ b/packages/core/src/current-host/catalog-operations.ts
@@ -9,11 +9,12 @@ import {
 import { CapletsError } from "../errors";
 import {
   currentHostCatalogDetail,
+  currentHostCatalogIndex,
   currentHostCatalogInstallSource,
   currentHostCatalogSearch,
   currentHostCatalogUpdateReadiness,
   currentHostInstalledCaplets,
-  currentHostSetupActionsForInstalled,
+  type CurrentHostSetupAction,
 } from "./catalog";
 import type {
   CurrentHostControlContext,
@@ -25,12 +26,14 @@ import type {
 
 type CapletsListOperation = Extract;
 type CatalogSearchOperation = Extract;
+type CatalogIndexOperation = Extract;
 type CatalogDetailOperation = Extract;
 type CatalogUpdatesOperation = Extract;
 type CatalogInstallOperation = Extract;
 type CatalogUpdateOperation = Extract;
 type CapletsListOutcome = Extract;
 type CatalogSearchOutcome = Extract;
+type CatalogIndexOutcome = Extract;
 type CatalogDetailOutcome = Extract;
 type CatalogUpdatesOutcome = Extract;
 type CatalogInstallOutcome = Extract;
@@ -47,13 +50,17 @@ export function createCurrentHostCatalogOperations(
         globalLockfilePath: dependencies.control?.globalLockfilePath,
       }),
     }),
-    search: (operation: CatalogSearchOperation): CatalogSearchOutcome => ({
+    search: async (operation: CatalogSearchOperation): Promise => ({
       kind: "catalog_search",
-      ...currentHostCatalogSearch(operation),
+      ...(await currentHostCatalogSearch(operation)),
     }),
-    detail: (operation: CatalogDetailOperation): CatalogDetailOutcome => ({
+    index: async (operation: CatalogIndexOperation): Promise => ({
+      kind: "catalog_index",
+      ...(await currentHostCatalogIndex(operation)),
+    }),
+    detail: async (operation: CatalogDetailOperation): Promise => ({
       kind: "catalog_detail",
-      ...currentHostCatalogDetail(operation),
+      ...(await currentHostCatalogDetail(operation)),
     }),
     updates: (_operation: CatalogUpdatesOperation): CatalogUpdatesOutcome => ({
       kind: "catalog_updates",
@@ -77,7 +84,8 @@ async function catalogInstallOutcome(
   principal: CurrentHostOperatorPrincipal,
   operation: CatalogInstallOperation,
 ): Promise {
-  const capletIds = optionalCapletIds(operation.capletIds);
+  let setupActions: CurrentHostSetupAction[] = [];
+  let capletIds = optionalCapletIds(operation.capletIds);
   if (operation.source !== undefined && operation.repo !== undefined) {
     throw new CapletsError(
       "REQUEST_INVALID",
@@ -87,11 +95,32 @@ async function catalogInstallOutcome(
   try {
     const control = requireControlContext(dependencies.control, "Catalog actions");
     const target = globalCatalogTarget(control);
-    const repo =
-      operation.repo ??
-      (operation.source === undefined
-        ? undefined
-        : currentHostCatalogInstallSource(operation.source));
+    let repo = operation.repo;
+    if (operation.source !== undefined) {
+      if (!operation.entryKey) {
+        throw new CapletsError("REQUEST_INVALID", "Catalog install requires a stable entryKey.");
+      }
+      const detail = await currentHostCatalogDetail({
+        source: operation.source,
+        entryKey: operation.entryKey,
+      });
+      const officialInstall =
+        operation.source.trim() !== "official" ||
+        (detail.entry.installCommand.copyable &&
+          detail.entry.installCommand.revisionBound === true &&
+          typeof detail.entry.resolvedRevision === "string" &&
+          detail.entry.resolvedRevision.length > 0);
+      if (
+        typeof detail.entry.contentMarkdown !== "string" ||
+        detail.entry.contentMarkdown.length === 0 ||
+        !officialInstall
+      ) {
+        throw new CapletsError("REQUEST_INVALID", "Catalog entry is not currently installable.");
+      }
+      setupActions = detail.setupActions;
+      capletIds = [detail.entry.id];
+      repo = currentHostCatalogInstallSource(operation.source, detail.entry.resolvedRevision);
+    }
     const installOptions = {
       ...target,
       ...(capletIds === undefined ? {} : { capletIds }),
@@ -109,13 +138,6 @@ async function catalogInstallOutcome(
         metadata: { status: entry.status ?? null, kind: entry.kind },
       });
     }
-    const catalogSource = operation.source;
-    const setupActions =
-      catalogSource === undefined
-        ? []
-        : installed.flatMap((entry) =>
-            currentHostSetupActionsForInstalled(catalogSource, entry.id),
-          );
     return { kind: "catalog_install", installed, setupActions };
   } catch (error) {
     appendCatalogFailureActivities(dependencies, principal, "catalog_installed", capletIds);
diff --git a/packages/core/src/current-host/catalog.ts b/packages/core/src/current-host/catalog.ts
index 527c726c..22cbfbb8 100644
--- a/packages/core/src/current-host/catalog.ts
+++ b/packages/core/src/current-host/catalog.ts
@@ -1,6 +1,5 @@
-import { existsSync, readFileSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
 import { discoverCapletFiles } from "../caplet-files";
 import type { CapletConfig } from "../config";
 import { defaultCapletsLockfilePath } from "../config";
@@ -15,10 +14,13 @@ import {
   catalogStringFromFrontmatter,
   catalogUsesLocalControlFromFrontmatter,
   catalogWorkflowSummaryFromFrontmatter,
+  catalogIconReferenceFromValue,
   createCatalogEntry,
+  generateCatalogInstallCommand,
   normalizeCatalogSourceIdentity,
   readCatalogCapletFrontmatterFromMarkdown,
   type CatalogEntry,
+  type CatalogCompactEntry,
   type CatalogSourceIdentity,
 } from "../catalog";
 
@@ -104,12 +106,12 @@ export function currentHostInstalledCaplets(
     .sort((left, right) => left.id.localeCompare(right.id));
 }
 
-export function currentHostCatalogSearch(input: {
+export async function currentHostCatalogSearch(input: {
   source: string;
   query?: string | undefined;
   limit?: number | undefined;
-}): { entries: CatalogEntry[] } {
-  const entries = catalogEntriesFromSource(input.source);
+}): Promise<{ entries: CatalogEntry[] }> {
+  const entries = await catalogEntriesFromSource(input.source);
   const query = input.query?.trim().toLowerCase();
   const filtered = query
     ? entries.filter((entry) =>
@@ -122,16 +124,28 @@ export function currentHostCatalogSearch(input: {
   return { entries: filtered.slice(0, boundedLimit(input.limit)) };
 }
 
-export function currentHostCatalogDetail(input: { source: string; capletId: string }): {
+export async function currentHostCatalogDetail(input: {
+  source: string;
+  entryKey: string;
+}): Promise<{
   entry: CatalogEntry;
   setupActions: CurrentHostSetupAction[];
   projectScopedInstallAvailable: false;
-} {
-  const entry = catalogEntriesFromSource(input.source).find(
-    (candidate) => candidate.id === input.capletId,
-  );
+}> {
+  let entry: CatalogEntry | undefined;
+  try {
+    entry =
+      input.source.trim() === "official"
+        ? await fetchOfficialCatalogDetail(input.entryKey)
+        : (await catalogEntriesFromSource(input.source)).find(
+            (candidate) => candidate.entryKey === input.entryKey,
+          );
+  } catch (error) {
+    if (error instanceof CapletsError) throw error;
+    throw new CapletsError("CONFIG_NOT_FOUND", `Catalog entry ${input.entryKey} not found.`);
+  }
   if (!entry)
-    throw new CapletsError("CONFIG_NOT_FOUND", `Catalog Caplet ${input.capletId} not found.`);
+    throw new CapletsError("CONFIG_NOT_FOUND", `Catalog entry ${input.entryKey} not found.`);
   return {
     entry,
     setupActions: setupActionsForEntry(entry),
@@ -139,6 +153,21 @@ export function currentHostCatalogDetail(input: { source: string; capletId: stri
   };
 }
 
+export async function currentHostCatalogIndex(input: {
+  source: string;
+}): Promise<{ entries: CatalogCompactEntry[] }> {
+  if (input.source.trim() === "official") return { entries: await fetchOfficialCatalogIndex() };
+  const entries = await catalogEntriesFromSource(input.source);
+  return {
+    entries: entries.map(({ contentMarkdown: _contentMarkdown, ...entry }) => ({
+      ...entry,
+      installCount: 0,
+      installCountDisplay: "<10",
+      rankScore: 0,
+    })),
+  };
+}
+
 export function currentHostCatalogUpdateReadiness(input: { context: CurrentHostCatalogContext }): {
   updates: Array<{ id: string; status: "locked"; risk: unknown }>;
 } {
@@ -151,21 +180,15 @@ export function currentHostCatalogUpdateReadiness(input: { context: CurrentHostC
   };
 }
 
-export function currentHostSetupActionsForInstalled(
-  source: string,
-  capletId: string,
-): CurrentHostSetupAction[] {
-  try {
-    return setupActionsForEntry(currentHostCatalogDetail({ source, capletId }).entry);
-  } catch {
-    return [];
-  }
-}
-
 const officialCatalogRepository = "spiritledsoftware/caplets";
+const officialCatalogApiUrl = "https://catalog.caplets.dev/api/v1/catalog";
+const maxCatalogEntries = 10_000;
+const maxCatalogIndexBytes = 32 * 1024 * 1024;
+const maxCatalogEntryBytes = 2 * 1024 * 1024;
 
-export function currentHostCatalogInstallSource(source: string): string {
-  return source.trim() === "official" ? officialCatalogRepository : source;
+export function currentHostCatalogInstallSource(source: string, resolvedRevision?: string): string {
+  const repository = source.trim() === "official" ? officialCatalogRepository : source;
+  return resolvedRevision ? `${repository}#${resolvedRevision}` : repository;
 }
 
 type ResolvedCurrentHostCatalogSource = {
@@ -176,7 +199,8 @@ type ResolvedCurrentHostCatalogSource = {
 
 type LockEntry = { id: string; source?: { type?: string; repository?: string }; risk?: unknown };
 
-function catalogEntriesFromSource(sourceInput: string): CatalogEntry[] {
+async function catalogEntriesFromSource(sourceInput: string): Promise {
+  if (sourceInput.trim() === "official") return await fetchOfficialCatalogEntries();
   const resolvedSource = resolveCurrentHostCatalogSource(sourceInput);
   const sourceRoot = join(resolvedSource.root, "caplets");
   const files = discoverCapletFiles(sourceRoot);
@@ -218,14 +242,6 @@ function catalogEntriesFromSource(sourceInput: string): CatalogEntry[] {
 }
 
 function resolveCurrentHostCatalogSource(sourceInput: string): ResolvedCurrentHostCatalogSource {
-  if (sourceInput.trim() === "official") {
-    const source = normalizeCatalogSourceIdentity(officialCatalogRepository);
-    if (!source.eligible) {
-      throw new CapletsError("CONFIG_INVALID", "Official catalog source is invalid.");
-    }
-    return { root: officialCatalogRoot(), source: source.source, trustLevel: "official" };
-  }
-
   const source = normalizeCatalogSourceIdentity(sourceInput);
   return {
     root: sourceInput,
@@ -237,22 +253,293 @@ function resolveCurrentHostCatalogSource(sourceInput: string): ResolvedCurrentHo
   };
 }
 
-function officialCatalogRoot(): string {
-  for (const start of [process.cwd(), dirname(fileURLToPath(import.meta.url))]) {
-    const found = findRepoRootWithCaplets(start);
-    if (found) return found;
+async function fetchOfficialCatalogEntries(): Promise {
+  const payload = await fetchOfficialJson(officialCatalogApiUrl, maxCatalogIndexBytes);
+  if (
+    !isRecord(payload) ||
+    payload.version !== 1 ||
+    !Array.isArray(payload.entries) ||
+    payload.entries.length > maxCatalogEntries ||
+    !payload.entries.every(isCatalogEntry)
+  ) {
+    throw invalidCatalogResponse();
   }
-  return process.cwd();
+  return payload.entries;
 }
 
-function findRepoRootWithCaplets(start: string): string | undefined {
-  let dir = resolve(start);
-  while (true) {
-    if (existsSync(join(dir, "caplets")) && existsSync(join(dir, "package.json"))) return dir;
-    const parent = dirname(dir);
-    if (parent === dir) return undefined;
-    dir = parent;
+async function fetchOfficialCatalogIndex(): Promise {
+  const payload = await fetchOfficialJson(
+    `${officialCatalogApiUrl}?view=compact`,
+    maxCatalogIndexBytes,
+  );
+  if (
+    !isRecord(payload) ||
+    payload.version !== 1 ||
+    payload.view !== "compact" ||
+    !Array.isArray(payload.entries) ||
+    payload.entries.length > maxCatalogEntries ||
+    !payload.entries.every(isCompactCatalogEntry)
+  ) {
+    throw invalidCatalogResponse();
+  }
+  return payload.entries;
+}
+
+async function fetchOfficialCatalogDetail(entryKey: string): Promise {
+  const payload = await fetchOfficialJson(
+    `${officialCatalogApiUrl}/entries/${encodeURIComponent(entryKey)}`,
+    maxCatalogEntryBytes,
+    true,
+  );
+  if (payload === undefined) return undefined;
+  if (
+    !isRecord(payload) ||
+    payload.version !== 1 ||
+    !isCatalogEntry(payload.entry) ||
+    payload.entry.entryKey !== entryKey
+  ) {
+    throw invalidCatalogResponse();
+  }
+  return payload.entry;
+}
+
+async function fetchOfficialJson(
+  url: string,
+  maxBytes: number,
+  missingAllowed = false,
+): Promise {
+  let response: Response;
+  try {
+    response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
+  } catch {
+    throw new CapletsError("SERVER_UNAVAILABLE", "Official catalog service is unavailable.");
   }
+  if (missingAllowed && response.status === 404) return undefined;
+  if (!response.ok) {
+    throw new CapletsError("SERVER_UNAVAILABLE", "Official catalog service is unavailable.");
+  }
+  const declaredLength = Number(response.headers.get("content-length"));
+  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) throw invalidCatalogResponse();
+  let text: string;
+  try {
+    const reader = response.body?.getReader();
+    if (!reader) throw new Error("missing body");
+    const decoder = new TextDecoder();
+    let bytes = 0;
+    const chunks: string[] = [];
+    for (;;) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      bytes += value.byteLength;
+      if (bytes > maxBytes) {
+        await reader.cancel();
+        throw invalidCatalogResponse();
+      }
+      chunks.push(decoder.decode(value, { stream: true }));
+    }
+    chunks.push(decoder.decode());
+    text = chunks.join("");
+    return JSON.parse(text) as unknown;
+  } catch (error) {
+    if (error instanceof CapletsError) throw error;
+    throw invalidCatalogResponse();
+  }
+}
+
+function invalidCatalogResponse(): CapletsError {
+  return new CapletsError(
+    "DOWNSTREAM_PROTOCOL_ERROR",
+    "Official catalog service returned an invalid response.",
+  );
+}
+
+function isCatalogEntry(value: unknown): value is CatalogEntry {
+  return (
+    isCatalogEntryBase(value) &&
+    typeof value.contentMarkdown === "string" &&
+    value.contentMarkdown.length <= maxCatalogEntryBytes
+  );
+}
+
+function isCompactCatalogEntry(value: unknown): value is CatalogCompactEntry {
+  if (!isRecord(value) || !isCatalogEntryBase(value) || "contentMarkdown" in value) return false;
+  const compact = value as unknown as Record;
+  return (
+    Number.isSafeInteger(compact.installCount) &&
+    (compact.installCount as number) >= 0 &&
+    boundedString(compact.installCountDisplay, 64) &&
+    typeof compact.rankScore === "number" &&
+    Number.isFinite(compact.rankScore)
+  );
+}
+
+const readinessValues: Record = { ready: true, required: true, unknown: true };
+const workflowKinds: Record = {
+  code_mode: true,
+  mcp: true,
+  openapi: true,
+  google_discovery: true,
+  graphql: true,
+  http: true,
+  cli: true,
+  set: true,
+  unknown: true,
+};
+const warningCodes: Record = {
+  unverified_community: true,
+  local_control: true,
+  mutating_saas: true,
+  auth_required: true,
+  setup_required: true,
+  project_binding_required: true,
+  readiness_unknown: true,
+};
+const warningSeverities: Record = { info: true, caution: true, danger: true };
+
+function isCatalogEntryBase(value: unknown): value is CatalogEntry {
+  if (!isRecord(value)) return false;
+  return (
+    boundedString(value.entryKey, 2048) &&
+    boundedString(value.id, 256) &&
+    boundedString(value.name, 1024) &&
+    boundedString(value.description, 16_384) &&
+    boundedString(value.sourcePath, 4096) &&
+    isOfficialSource(value.source) &&
+    value.trustLevel === "official" &&
+    optionalBoundedString(value.resolvedRevision, 256) &&
+    optionalBoundedString(value.indexedContentHash, 256) &&
+    Array.isArray(value.tags) &&
+    value.tags.length <= 100 &&
+    value.tags.every((tag) => boundedString(tag, 256)) &&
+    boundedString(value.intendedTask, 4096) &&
+    optionalBoundedString(value.avoidWhen, 4096) &&
+    typeof value.setupReadiness === "string" &&
+    value.setupReadiness in readinessValues &&
+    typeof value.authReadiness === "string" &&
+    value.authReadiness in readinessValues &&
+    typeof value.projectBindingReadiness === "string" &&
+    value.projectBindingReadiness in readinessValues &&
+    isWorkflow(value.workflow) &&
+    isChildren(value.children) &&
+    isInstallCommand(value.installCommand, value) &&
+    Array.isArray(value.warnings) &&
+    value.warnings.length <= 100 &&
+    value.warnings.every(isWarning) &&
+    isIcon(value.icon)
+  );
+}
+
+function isOfficialSource(value: unknown): boolean {
+  return (
+    isRecord(value) &&
+    value.provider === "github" &&
+    value.owner === "spiritledsoftware" &&
+    value.repo === "caplets" &&
+    value.repository === officialCatalogRepository &&
+    value.canonicalUrl === "https://github.com/spiritledsoftware/caplets"
+  );
+}
+
+function isWorkflow(value: unknown): boolean {
+  return (
+    isRecord(value) &&
+    typeof value.kind === "string" &&
+    value.kind in workflowKinds &&
+    boundedString(value.label, 256)
+  );
+}
+
+function isChildren(value: unknown): boolean {
+  return (
+    value === undefined ||
+    (Array.isArray(value) &&
+      value.length <= 1_000 &&
+      value.every(
+        (child) =>
+          isRecord(child) &&
+          boundedString(child.id, 256) &&
+          optionalBoundedString(child.childId, 256) &&
+          boundedString(child.name, 1024) &&
+          boundedString(child.backend, 256) &&
+          isWorkflow(child.workflow),
+      ))
+  );
+}
+
+function isInstallCommand(value: unknown, entry: Record): boolean {
+  if (
+    !isRecord(value) ||
+    typeof value.copyable !== "boolean" ||
+    typeof value.revisionBound !== "boolean" ||
+    !boundedString(value.text, 16_384) ||
+    !optionalBoundedString(value.reason, 64)
+  )
+    return false;
+  const expected = generateCatalogInstallCommand({
+    source: {
+      provider: "github",
+      owner: "spiritledsoftware",
+      repo: "caplets",
+      repository: officialCatalogRepository,
+      canonicalUrl: "https://github.com/spiritledsoftware/caplets",
+    },
+    capletId: entry.id as string,
+    ...(typeof entry.resolvedRevision === "string"
+      ? { resolvedRevision: entry.resolvedRevision }
+      : {}),
+  });
+  return (
+    value.text === expected.text &&
+    value.copyable === expected.copyable &&
+    value.revisionBound === expected.revisionBound &&
+    value.reason === expected.reason
+  );
+}
+
+function isWarning(value: unknown): boolean {
+  return (
+    isRecord(value) &&
+    typeof value.code === "string" &&
+    value.code in warningCodes &&
+    typeof value.severity === "string" &&
+    value.severity in warningSeverities &&
+    boundedString(value.label, 256) &&
+    boundedString(value.message, 4096)
+  );
+}
+
+function isIcon(value: unknown): boolean {
+  if (value === undefined) return true;
+  if (!isRecord(value)) return false;
+  if (value.type === "url") {
+    const reference = catalogIconReferenceFromValue(value.url);
+    return reference?.type === "url";
+  }
+  if (
+    value.type !== "bundled" ||
+    !boundedString(value.path, 4096) ||
+    !boundedString(value.url, 4096)
+  ) {
+    return false;
+  }
+  const reference = catalogIconReferenceFromValue(value.path);
+  return (
+    reference?.type === "bundled" &&
+    value.url.startsWith("/catalog-icons/official/") &&
+    !value.url.includes("\\")
+  );
+}
+
+function optionalBoundedString(value: unknown, max: number): boolean {
+  return value === undefined || boundedString(value, max);
+}
+
+function boundedString(value: unknown, max: number): value is string {
+  return typeof value === "string" && value.length <= max;
+}
+
+function isRecord(value: unknown): value is Record {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
 }
 
 function localCatalogSource(): CatalogSourceIdentity {
diff --git a/packages/core/src/current-host/operations.ts b/packages/core/src/current-host/operations.ts
index aa43b80c..7c7e549c 100644
--- a/packages/core/src/current-host/operations.ts
+++ b/packages/core/src/current-host/operations.ts
@@ -1,4 +1,4 @@
-import type { CatalogEntry } from "../catalog";
+import type { CatalogCompactEntry, CatalogEntry } from "../catalog";
 import {
   DashboardActivityLog,
   type DashboardActivityAction,
@@ -101,12 +101,15 @@ export type CurrentHostOperation =
       query?: string | undefined;
       limit?: number | undefined;
     }
-  | { kind: "catalog_detail"; source: string; capletId: string }
+  | { kind: "catalog_index"; source: string }
+  | { kind: "catalog_detail"; source: string; entryKey: string }
   | { kind: "catalog_updates" }
   | {
       kind: "catalog_install";
       /** A dashboard catalog source. When present, it also determines setup actions. */
       source?: string | undefined;
+      /** Stable catalog identity. Requires source and is independently re-resolved before install. */
+      entryKey?: string | undefined;
       /** A bearer-compatible repository source. Omit to restore the server lockfile. */
       repo?: string | undefined;
       capletIds?: string[] | undefined;
@@ -229,6 +232,7 @@ export type CurrentHostOperationOutcome =
   | { kind: "summary"; summary: CurrentHostSummary }
   | { kind: "caplets_list"; caplets: CurrentHostInstalledCaplet[] }
   | { kind: "catalog_search"; entries: CatalogEntry[] }
+  | { kind: "catalog_index"; entries: CatalogCompactEntry[] }
   | {
       kind: "catalog_detail";
       entry: CatalogEntry;
@@ -392,9 +396,11 @@ async function executeCurrentHostOperation(
     case "caplets_list":
       return catalog.capletsList(operation);
     case "catalog_search":
-      return catalog.search(operation);
+      return await catalog.search(operation);
+    case "catalog_index":
+      return await catalog.index(operation);
     case "catalog_detail":
-      return catalog.detail(operation);
+      return await catalog.detail(operation);
     case "catalog_updates":
       return catalog.updates(operation);
     case "catalog_install":
diff --git a/packages/core/src/dashboard/routes.ts b/packages/core/src/dashboard/routes.ts
index 1809ac9c..d398b79f 100644
--- a/packages/core/src/dashboard/routes.ts
+++ b/packages/core/src/dashboard/routes.ts
@@ -35,7 +35,7 @@ export function dashboardStaticResponse(
 
 function dashboardStaticFilePath(requestPath: string, distDir: string): string | undefined {
   const decodedPath = safeDecodePath(requestPath);
-  if (!decodedPath) return undefined;
+  if (!decodedPath || hasUnsafePathSegment(decodedPath)) return undefined;
   if (decodedPath.startsWith("/_astro/")) {
     return safeJoin(distDir, decodedPath.slice(1));
   }
@@ -46,10 +46,26 @@ function dashboardStaticFilePath(requestPath: string, distDir: string): string |
   if (decodedPath.startsWith("/dashboard/api/")) return undefined;
   const route = decodedPath.slice("/dashboard/".length).replace(/\/$/u, "");
   if (!route) return safeJoin(distDir, "dashboard/index.html");
+  if (isCatalogDetailRequest(requestPath)) {
+    return safeJoin(distDir, "dashboard/catalog/index.html");
+  }
   if (route.includes(".")) return safeJoin(distDir, route);
   return safeJoin(distDir, `dashboard/${route}/index.html`);
 }
 
+function hasUnsafePathSegment(path: string): boolean {
+  return path.split("/").some((segment) => segment === "." || segment === "..");
+}
+
+function isCatalogDetailRequest(requestPath: string): boolean {
+  const normalizedPath = requestPath.replace(/\/+$/u, "");
+  const prefix = "/dashboard/catalog/";
+  if (!normalizedPath.startsWith(prefix)) return false;
+  const encodedEntryKey = normalizedPath.slice(prefix.length);
+  if (!encodedEntryKey || encodedEntryKey.includes("/")) return false;
+  return !/\.(?:css|gif|ico|jpe?g|js|json|mjs|png|svg|webp)$/iu.test(encodedEntryKey);
+}
+
 function safeDecodePath(value: string): string | undefined {
   try {
     return decodeURIComponent(value);
diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts
index 8f8a2ab1..583e5b9a 100644
--- a/packages/core/src/serve/http.ts
+++ b/packages/core/src/serve/http.ts
@@ -422,15 +422,19 @@ export function createHttpServeApp(
     if (!session.ok) return session.response;
     try {
       const query = new URL(c.req.url).searchParams;
-      const outcome = await currentHostOperations.execute(
-        dashboardPrincipalForSession(c, session.session),
-        {
-          kind: "catalog_search",
-          source: requiredQueryParam(query, "source"),
-          query: query.get("q") ?? undefined,
-          limit: numberQueryParam(query.get("limit")),
-        },
-      );
+      const source = requiredQueryParam(query, "source");
+      const outcome =
+        query.has("q") || query.has("limit")
+          ? await currentHostOperations.execute(dashboardPrincipalForSession(c, session.session), {
+              kind: "catalog_search",
+              source,
+              query: query.get("q") ?? undefined,
+              limit: numberQueryParam(query.get("limit")),
+            })
+          : await currentHostOperations.execute(dashboardPrincipalForSession(c, session.session), {
+              kind: "catalog_index",
+              source,
+            });
 
       return c.json({ entries: outcome.entries });
     } catch (error) {
@@ -448,7 +452,7 @@ export function createHttpServeApp(
         {
           kind: "catalog_detail",
           source: requiredQueryParam(query, "source"),
-          capletId: requiredQueryParam(query, "id"),
+          entryKey: requiredQueryParam(query, "entryKey"),
         },
       );
 
@@ -490,7 +494,7 @@ export function createHttpServeApp(
         {
           kind: "catalog_install",
           source: stringField(parsed, "source"),
-          capletIds: [stringField(parsed, "capletId")],
+          entryKey: stringField(parsed, "entryKey"),
           force: optionalBooleanField(parsed, "force"),
           disableCatalogIndexing: true,
         },
diff --git a/packages/core/test/cli-remote.test.ts b/packages/core/test/cli-remote.test.ts
index 1895860b..06fa23fb 100644
--- a/packages/core/test/cli-remote.test.ts
+++ b/packages/core/test/cli-remote.test.ts
@@ -1226,6 +1226,16 @@ describe("remote CLI routing", () => {
       `Installed github to global ${join(dirname(context.configPath), "github")}`,
     );
     expect(fetchMock).not.toHaveBeenCalled();
+    expect(
+      JSON.parse(
+        readFileSync(
+          join(dirname(context.configPath), "state", "caplets", "caplets.lock.json"),
+          "utf8",
+        ),
+      ),
+    ).toMatchObject({
+      entries: [expect.objectContaining({ id: "github" })],
+    });
   });
 
   it("routes install through remote control with --remote", async () => {
@@ -1700,6 +1710,7 @@ function remoteEnv(context: {
     CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets",
     CAPLETS_CONFIG: context.configPath,
     CAPLETS_PROJECT_CONFIG: context.projectConfigPath,
+    XDG_STATE_HOME: join(dirname(context.configPath), "state"),
     ...(context.authDir
       ? { CAPLETS_CLOUD_AUTH_PATH: join(context.authDir, "cloud-auth.json") }
       : {}),
diff --git a/packages/core/test/current-host-administration.test.ts b/packages/core/test/current-host-administration.test.ts
index c58d7503..d52340a3 100644
--- a/packages/core/test/current-host-administration.test.ts
+++ b/packages/core/test/current-host-administration.test.ts
@@ -107,10 +107,17 @@ describe("Current Host administration operations", () => {
     const setup = testOperations();
     try {
       const source = catalogSource(setup.root);
+      const catalog = await setup.operations.execute(setup.principal, {
+        kind: "catalog_search",
+        source,
+      });
+      if (catalog.kind !== "catalog_search") throw new Error("Expected catalog search outcome.");
+      const entryKey = catalog.entries.find((entry) => entry.id === "sample")?.entryKey;
+      if (!entryKey) throw new Error("Expected sample catalog entry.");
       const installed = await setup.operations.execute(setup.principal, {
         kind: "catalog_install",
         source,
-        capletIds: ["sample"],
+        entryKey,
         disableCatalogIndexing: true,
       });
       const updates = await setup.operations.execute(setup.principal, { kind: "catalog_updates" });
diff --git a/packages/core/test/current-host-catalog-operations.test.ts b/packages/core/test/current-host-catalog-operations.test.ts
new file mode 100644
index 00000000..abf9b765
--- /dev/null
+++ b/packages/core/test/current-host-catalog-operations.test.ts
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createCurrentHostCatalogOperations } from "../src/current-host/catalog-operations";
+import type * as CatalogModule from "../src/current-host/catalog";
+import type * as InstallModule from "../src/cli/install";
+import type {
+  CurrentHostOperationsDependencies,
+  CurrentHostOperatorPrincipal,
+} from "../src/current-host/operations";
+
+const mocks = vi.hoisted(() => ({
+  detail: vi.fn(),
+  install: vi.fn(),
+}));
+
+vi.mock("../src/current-host/catalog", async (importOriginal) => ({
+  ...(await importOriginal()),
+  currentHostCatalogDetail: mocks.detail,
+}));
+
+vi.mock("../src/cli/install", async (importOriginal) => ({
+  ...(await importOriginal()),
+  installCaplets: mocks.install,
+}));
+
+const principal: CurrentHostOperatorPrincipal = {
+  clientId: "operator",
+  clientLabel: "Operator",
+  hostUrl: "http://127.0.0.1/",
+  role: "operator",
+};
+
+const dependencies = {
+  control: {
+    configPath: "/tmp/config.json",
+    projectConfigPath: "/tmp/project.json",
+    authDir: "/tmp/auth",
+    globalCapletsRoot: "/tmp/caplets",
+    globalLockfilePath: "/tmp/caplets.lock.json",
+  },
+  activityLog: { append: vi.fn() },
+} as unknown as CurrentHostOperationsDependencies;
+
+function officialDetail(
+  overrides: {
+    copyable?: boolean;
+    revisionBound?: boolean;
+    resolvedRevision?: string;
+  } = {},
+) {
+  return {
+    entry: {
+      id: "github",
+      contentMarkdown: "# GitHub",
+      installCommand: {
+        text: "caplets install spiritledsoftware/caplets#abc123 github",
+        copyable: overrides.copyable ?? true,
+        revisionBound: overrides.revisionBound ?? true,
+      },
+      ...(overrides.resolvedRevision === undefined
+        ? {}
+        : { resolvedRevision: overrides.resolvedRevision }),
+    },
+    setupActions: [],
+  };
+}
+
+describe("Current Host official catalog installs", () => {
+  beforeEach(() => {
+    mocks.detail.mockReset();
+    mocks.install.mockReset();
+    mocks.install.mockReturnValue({ installed: [{ id: "github", kind: "caplet" }] });
+  });
+
+  it.each([
+    ["an unbound command", { revisionBound: false, resolvedRevision: "abc123" }],
+    [
+      "a non-copyable command",
+      { copyable: false, revisionBound: true, resolvedRevision: "abc123" },
+    ],
+    ["a missing resolved revision", { revisionBound: true }],
+    ["an empty resolved revision", { revisionBound: true, resolvedRevision: "" }],
+  ])("rejects %s before invoking install machinery", async (_label, detailOverrides) => {
+    mocks.detail.mockResolvedValue(officialDetail(detailOverrides));
+    const operations = createCurrentHostCatalogOperations(dependencies);
+
+    await expect(
+      operations.install(principal, {
+        kind: "catalog_install",
+        source: "official",
+        entryKey: "official-entry",
+        disableCatalogIndexing: true,
+      }),
+    ).rejects.toMatchObject({
+      code: "REQUEST_INVALID",
+      message: "Catalog entry is not currently installable.",
+    });
+    expect(mocks.install).not.toHaveBeenCalled();
+  });
+
+  it("installs an official entry from its resolved revision", async () => {
+    mocks.detail.mockResolvedValue(officialDetail({ resolvedRevision: "abc123" }));
+    const operations = createCurrentHostCatalogOperations(dependencies);
+
+    await expect(
+      operations.install(principal, {
+        kind: "catalog_install",
+        source: "official",
+        entryKey: "official-entry",
+        disableCatalogIndexing: true,
+      }),
+    ).resolves.toMatchObject({ kind: "catalog_install" });
+    expect(mocks.install).toHaveBeenCalledWith(
+      "spiritledsoftware/caplets#abc123",
+      expect.objectContaining({ capletIds: ["github"] }),
+    );
+  });
+});
diff --git a/packages/core/test/dashboard-catalog.test.ts b/packages/core/test/dashboard-catalog.test.ts
index bb1fc24e..4cdab2fc 100644
--- a/packages/core/test/dashboard-catalog.test.ts
+++ b/packages/core/test/dashboard-catalog.test.ts
@@ -1,7 +1,8 @@
 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { currentHostCatalogInstallSource } from "../src/current-host/catalog";
 import { CapletsEngine } from "../src/engine";
 import { RemoteServerCredentialStore } from "../src/remote/server-credential-store";
 import { createHttpServeApp, type CapletsHttpApp } from "../src/serve/http";
@@ -11,11 +12,55 @@ const dirs: string[] = [];
 
 afterEach(() => {
   for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+  vi.restoreAllMocks();
 });
 
 describe("dashboard caplets and catalog APIs", () => {
-  it("serves the checked-in official catalog for dashboard browsing", async () => {
+  it("serves the official catalog API for dashboard browsing and detail", async () => {
     const setup = await authenticatedDashboard();
+    const officialEntryKey = "github:spiritledsoftware:caplets:github%2Fcaplet.md:github";
+    const officialEntry = {
+      entryKey: officialEntryKey,
+      id: "github",
+      name: "GitHub from API",
+      description: "Work with GitHub repositories.",
+      source: {
+        provider: "github",
+        owner: "spiritledsoftware",
+        repo: "caplets",
+        repository: "spiritledsoftware/caplets",
+        canonicalUrl: "https://github.com/spiritledsoftware/caplets",
+      },
+      sourcePath: "github/CAPLET.md",
+      trustLevel: "official",
+      tags: ["github"],
+      intendedTask: "Work with GitHub repositories.",
+      setupReadiness: "ready",
+      authReadiness: "required",
+      projectBindingReadiness: "ready",
+      warnings: [],
+      installCommand: {
+        text: "caplets install spiritledsoftware/caplets github",
+        copyable: true,
+        revisionBound: false,
+      },
+      workflow: { kind: "code_mode", label: "Code Mode" },
+      installCount: 0,
+      installCountDisplay: "<10",
+      rankScore: 0,
+    };
+    const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
+      if (String(url).includes("/entries/")) {
+        return Response.json({
+          version: 1,
+          entry: { ...officialEntry, contentMarkdown: "# GitHub from catalog.caplets.dev" },
+        });
+      }
+      return Response.json({
+        version: 1,
+        entries: [{ ...officialEntry, contentMarkdown: "# GitHub" }],
+      });
+    });
 
     const search = await dashboardGet(
       setup,
@@ -26,15 +71,225 @@ describe("dashboard caplets and catalog APIs", () => {
       entries: [
         expect.objectContaining({
           id: "github",
-          name: "GitHub",
+          name: "GitHub from API",
           source: expect.objectContaining({ repository: "spiritledsoftware/caplets" }),
-          installCommand: expect.objectContaining({
-            text: "caplets install spiritledsoftware/caplets github",
-          }),
         }),
       ],
     });
 
+    const detail = await dashboardGet(
+      setup,
+      `/dashboard/api/catalog/detail?source=official&entryKey=${encodeURIComponent(officialEntryKey)}`,
+    );
+    expect(detail.status).toBe(200);
+    await expect(detail.json()).resolves.toMatchObject({
+      entry: {
+        id: "github",
+        contentMarkdown: "# GitHub from catalog.caplets.dev",
+      },
+    });
+    expect(fetchMock).toHaveBeenCalledWith("https://catalog.caplets.dev/api/v1/catalog", {
+      signal: expect.any(AbortSignal),
+    });
+
+    await setup.engine.close();
+  });
+
+  it("returns all 150 compact official entries without the former search ceiling", async () => {
+    const setup = await authenticatedDashboard();
+    const entries = Array.from({ length: 150 }, (_, index) => officialCompactEntry(index));
+    vi.spyOn(globalThis, "fetch").mockResolvedValue(
+      Response.json({ version: 1, view: "compact", entries }),
+    );
+
+    const response = await dashboardGet(setup, "/dashboard/api/catalog/search?source=official");
+    expect(response.status).toBe(200);
+    const body = (await response.json()) as { entries: Array> };
+    expect(body.entries).toHaveLength(150);
+    expect(body.entries[149]).toMatchObject({ id: "entry-149" });
+    expect(body.entries.some((entry) => "contentMarkdown" in entry)).toBe(false);
+
+    await setup.engine.close();
+  });
+
+  it("preserves legacy catalog search limits when query parameters are present", async () => {
+    const setup = await authenticatedDashboard();
+    const entries = Array.from({ length: 5 }, (_, index) => ({
+      ...officialCompactEntry(index),
+      contentMarkdown: `# Entry ${index}`,
+    }));
+    vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ version: 1, entries }));
+
+    const response = await dashboardGet(
+      setup,
+      "/dashboard/api/catalog/search?source=official&limit=2",
+    );
+    expect(response.status).toBe(200);
+    const body = (await response.json()) as { entries: Array> };
+    expect(body.entries).toHaveLength(2);
+    expect(body.entries).toEqual([
+      expect.objectContaining({ id: "entry-0" }),
+      expect.objectContaining({ id: "entry-1" }),
+    ]);
+
+    await setup.engine.close();
+  });
+
+  it("rejects an official compact index above 10,000 entries as a protocol error", async () => {
+    const setup = await authenticatedDashboard();
+    const entry = officialCompactEntry(0);
+    vi.spyOn(globalThis, "fetch").mockResolvedValue(
+      Response.json({
+        version: 1,
+        view: "compact",
+        entries: Array.from({ length: 10_001 }, () => entry),
+      }),
+    );
+
+    const response = await dashboardGet(setup, "/dashboard/api/catalog/search?source=official");
+    expect(response.status).toBe(500);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: false,
+      error: { code: "DOWNSTREAM_PROTOCOL_ERROR" },
+    });
+
+    await setup.engine.close();
+  });
+
+  it("rejects overlong compact fields as a protocol error", async () => {
+    const setup = await authenticatedDashboard();
+    vi.spyOn(globalThis, "fetch").mockResolvedValue(
+      Response.json({
+        version: 1,
+        view: "compact",
+        entries: [{ ...officialCompactEntry(0), name: "x".repeat(1_025) }],
+      }),
+    );
+
+    const response = await dashboardGet(setup, "/dashboard/api/catalog/search?source=official");
+    expect(response.status).toBe(500);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: false,
+      error: { code: "DOWNSTREAM_PROTOCOL_ERROR" },
+    });
+
+    await setup.engine.close();
+  });
+
+  it.each([
+    [
+      "missing readiness",
+      (entry: Record) => {
+        delete entry.setupReadiness;
+      },
+    ],
+    [
+      "missing workflow",
+      (entry: Record) => {
+        delete entry.workflow;
+      },
+    ],
+    [
+      "null warning",
+      (entry: Record) => {
+        entry.warnings = [null];
+      },
+    ],
+    [
+      "invalid warning enum",
+      (entry: Record) => {
+        entry.warnings = [{ code: "bogus", severity: "info", label: "Warning", message: "Bad." }];
+      },
+    ],
+    [
+      "overlong warning",
+      (entry: Record) => {
+        entry.warnings = [
+          {
+            code: "auth_required",
+            severity: "info",
+            label: "x".repeat(257),
+            message: "Bad.",
+          },
+        ];
+      },
+    ],
+    [
+      "substituted repository",
+      (entry: Record) => {
+        entry.source = {
+          provider: "github",
+          owner: "attacker",
+          repo: "caplets",
+          repository: "attacker/caplets",
+          canonicalUrl: "https://github.com/attacker/caplets",
+        };
+      },
+    ],
+    [
+      "localhost icon",
+      (entry: Record) => {
+        entry.icon = { type: "url", url: "https://localhost/icon.png" };
+      },
+    ],
+    [
+      "HTTP icon",
+      (entry: Record) => {
+        entry.icon = { type: "url", url: "http://example.com/icon.png" };
+      },
+    ],
+  ])("rejects official compact entries with %s", async (_label, mutate) => {
+    const setup = await authenticatedDashboard();
+    const entry = officialCompactEntry(0) as Record;
+    mutate(entry);
+    vi.spyOn(globalThis, "fetch").mockResolvedValue(
+      Response.json({ version: 1, view: "compact", entries: [entry] }),
+    );
+
+    const response = await dashboardGet(setup, "/dashboard/api/catalog/search?source=official");
+    expect(response.status).toBe(500);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: false,
+      error: { code: "DOWNSTREAM_PROTOCOL_ERROR" },
+    });
+    await setup.engine.close();
+  });
+
+  it("pins official installer input to the inspected revision", () => {
+    expect(currentHostCatalogInstallSource("official", "abc123")).toBe(
+      "spiritledsoftware/caplets#abc123",
+    );
+  });
+
+  it("rejects official detail whose returned entryKey differs from the requested key", async () => {
+    const setup = await authenticatedDashboard();
+    const requested = officialCompactEntry(0);
+    vi.spyOn(globalThis, "fetch").mockResolvedValue(
+      Response.json({
+        version: 1,
+        entry: {
+          ...requested,
+          entryKey: "github:spiritledsoftware:caplets:other%2FCAPLET.md:other",
+          id: "other",
+          contentMarkdown: "# Other",
+          installCommand: {
+            text: "caplets install spiritledsoftware/caplets other",
+            copyable: true,
+            revisionBound: false,
+          },
+        },
+      }),
+    );
+
+    const response = await dashboardGet(
+      setup,
+      `/dashboard/api/catalog/detail?source=official&entryKey=${encodeURIComponent(requested.entryKey)}`,
+    );
+    expect(response.status).toBe(500);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: false,
+      error: { code: "DOWNSTREAM_PROTOCOL_ERROR" },
+    });
     await setup.engine.close();
   });
 
@@ -68,7 +323,7 @@ describe("dashboard caplets and catalog APIs", () => {
 
     const detail = await dashboardGet(
       setup,
-      `/dashboard/api/catalog/detail?source=${encodeURIComponent(source)}&id=sample`,
+      `/dashboard/api/catalog/detail?source=${encodeURIComponent(source)}&entryKey=${encodeURIComponent(await localEntryKey(setup, source))}`,
     );
     expect(detail.status).toBe(200);
     await expect(detail.json()).resolves.toMatchObject({
@@ -90,7 +345,7 @@ describe("dashboard caplets and catalog APIs", () => {
 
     const installed = await dashboardPost(setup, "/dashboard/api/catalog/install", {
       source,
-      capletId: "sample",
+      entryKey: await localEntryKey(setup, source),
     });
     expect(installed.status).toBe(200);
     await expect(installed.json()).resolves.toMatchObject({
@@ -131,7 +386,7 @@ describe("dashboard caplets and catalog APIs", () => {
 
     const installed = await dashboardPost(setup, "/dashboard/api/catalog/install", {
       source,
-      capletId: "sample",
+      entryKey: await localEntryKey(setup, source),
     });
     expect(installed.status).toBe(200);
     const installedPath = join(setup.context.globalCapletsRoot, "sample.md");
@@ -162,7 +417,7 @@ describe("dashboard caplets and catalog APIs", () => {
 
     const installed = await dashboardPost(setup, "/dashboard/api/catalog/install", {
       source,
-      capletId: "sample",
+      entryKey: await localEntryKey(setup, source),
     });
     expect(installed.status).toBe(200);
 
@@ -180,14 +435,14 @@ describe("dashboard caplets and catalog APIs", () => {
 
     await setup.engine.close();
   });
-  it("returns the same redacted catalog failure through dashboard and bearer adapters", async () => {
+  it("rejects dashboard install before an unvalidated source reaches installation", async () => {
     const setup = await authenticatedDashboard();
     const source =
       "https://operator:credential@127.0.0.1:1/private-repository?token=transport_secret";
 
     const dashboardResponse = await dashboardPost(setup, "/dashboard/api/catalog/install", {
       source,
-      capletId: "sample",
+      entryKey: "github:local:source:caplets%2Fsample.md:sample",
     });
     expect(dashboardResponse.status).toBe(404);
     const dashboardError = (await dashboardResponse.json()) as {
@@ -220,11 +475,11 @@ describe("dashboard caplets and catalog APIs", () => {
       error: { code: string; message: string };
     };
 
-    expect(dashboardError.error).toEqual({
+    expect(dashboardError.error).toMatchObject({ code: "CONFIG_NOT_FOUND" });
+    expect(bearerError.error).toMatchObject({
       code: "CONFIG_NOT_FOUND",
       message: "Could not clone repo [REDACTED]",
     });
-    expect(bearerError.error).toEqual(dashboardError.error);
     expect(JSON.stringify({ dashboardError, bearerError })).not.toContain("credential");
     expect(JSON.stringify({ dashboardError, bearerError })).not.toContain("transport_secret");
     expect(JSON.stringify({ dashboardError, bearerError })).not.toContain("127.0.0.1");
@@ -281,6 +536,17 @@ async function authenticatedDashboard(): Promise {
   };
 }
 
+async function localEntryKey(setup: AuthenticatedDashboard, source: string): Promise {
+  const response = await dashboardGet(
+    setup,
+    `/dashboard/api/catalog/search?source=${encodeURIComponent(source)}`,
+  );
+  const body = (await response.json()) as { entries: Array<{ entryKey: string }> };
+  const entryKey = body.entries[0]?.entryKey;
+  if (!entryKey) throw new Error("Missing local catalog entry");
+  return entryKey;
+}
+
 async function dashboardGet(setup: AuthenticatedDashboard, path: string) {
   return await setup.app.request(`http://127.0.0.1:5387${path}`, {
     headers: { cookie: setup.cookie },
@@ -414,6 +680,40 @@ function catalogSource(): string {
   return source;
 }
 
+function officialCompactEntry(index: number) {
+  const id = `entry-${index}`;
+  return {
+    entryKey: `github:spiritledsoftware:caplets:${id}%2Fcaplet.md:${id}`,
+    id,
+    name: `Entry ${index}`,
+    description: "Catalog entry.",
+    source: {
+      provider: "github",
+      owner: "spiritledsoftware",
+      repo: "caplets",
+      repository: "spiritledsoftware/caplets",
+      canonicalUrl: "https://github.com/spiritledsoftware/caplets",
+    },
+    sourcePath: `${id}/CAPLET.md`,
+    trustLevel: "official",
+    tags: ["test"],
+    intendedTask: "Test catalog behavior.",
+    setupReadiness: "ready",
+    authReadiness: "ready",
+    projectBindingReadiness: "ready",
+    warnings: [],
+    installCommand: {
+      text: `caplets install spiritledsoftware/caplets ${id}`,
+      copyable: true,
+      revisionBound: false,
+    },
+    workflow: { kind: "code_mode", label: "Code Mode" },
+    installCount: 0,
+    installCountDisplay: "<10",
+    rankScore: 0,
+  };
+}
+
 function tempDir(prefix: string): string {
   const dir = mkdtempSync(join(tmpdir(), prefix));
   dirs.push(dir);
diff --git a/packages/core/test/dashboard-static.test.ts b/packages/core/test/dashboard-static.test.ts
index 519eedee..27a8caee 100644
--- a/packages/core/test/dashboard-static.test.ts
+++ b/packages/core/test/dashboard-static.test.ts
@@ -17,6 +17,7 @@ describe("dashboard static serving", () => {
   it("serves built dashboard pages and assets while preserving API precedence", async () => {
     const dashboardDistDir = tempDir("caplets-dashboard-dist-");
     mkdirSync(join(dashboardDistDir, "dashboard", "access"), { recursive: true });
+    mkdirSync(join(dashboardDistDir, "dashboard", "catalog"), { recursive: true });
     mkdirSync(join(dashboardDistDir, "_astro"), { recursive: true });
     writeFileSync(
       join(dashboardDistDir, "dashboard", "index.html"),
@@ -26,6 +27,10 @@ describe("dashboard static serving", () => {
       join(dashboardDistDir, "dashboard", "access", "index.html"),
       '
Access
', ); + writeFileSync( + join(dashboardDistDir, "dashboard", "catalog", "index.html"), + '
Catalog
', + ); writeFileSync(join(dashboardDistDir, "_astro", "client.js"), "console.log('dashboard')"); writeFileSync(join(dashboardDistDir, "icon.png"), "png"); @@ -47,6 +52,12 @@ describe("dashboard static serving", () => { expect(access.status).toBe(200); await expect(access.text()).resolves.toContain("Access"); + const catalogDetail = await app.request( + "http://127.0.0.1:5387/dashboard/catalog/github%3Aowner%2Frepo%3Acaplet", + ); + expect(catalogDetail.status).toBe(200); + await expect(catalogDetail.text()).resolves.toContain("Catalog"); + const asset = await app.request("http://127.0.0.1:5387/_astro/client.js"); expect(asset.status).toBe(200); expect(asset.headers.get("content-type")).toContain("javascript"); @@ -65,11 +76,16 @@ describe("dashboard static serving", () => { it("serves dashboard HTML and immutable assets under a configured base path", async () => { const dashboardDistDir = tempDir("caplets-dashboard-base-dist-"); mkdirSync(join(dashboardDistDir, "dashboard"), { recursive: true }); + mkdirSync(join(dashboardDistDir, "dashboard", "catalog"), { recursive: true }); mkdirSync(join(dashboardDistDir, "_astro"), { recursive: true }); writeFileSync( join(dashboardDistDir, "dashboard", "index.html"), "
Base path dashboard
", ); + writeFileSync( + join(dashboardDistDir, "dashboard", "catalog", "index.html"), + "
Base path catalog
", + ); writeFileSync(join(dashboardDistDir, "_astro", "base.js"), "export const base = true;"); const { engine } = testEngine(); @@ -86,6 +102,12 @@ describe("dashboard static serving", () => { expect(dashboard.status).toBe(200); await expect(dashboard.text()).resolves.toBe("
Base path dashboard
"); + const catalogDetail = await app.request( + "http://127.0.0.1:5387/caplets/dashboard/catalog/github%3Aowner%2Frepo%3Acaplet", + ); + expect(catalogDetail.status).toBe(200); + await expect(catalogDetail.text()).resolves.toBe("
Base path catalog
"); + const asset = await app.request("http://127.0.0.1:5387/caplets/_astro/base.js"); expect(asset.status).toBe(200); await expect(asset.text()).resolves.toBe("export const base = true;"); @@ -108,6 +130,10 @@ describe("dashboard static serving", () => { dashboardStaticResponse("/dashboard/%2e%2e%2foutside-dashboard-secret.txt", dashboardDistDir), ).toBeUndefined(); expect(dashboardStaticResponse("/dashboard/%", dashboardDistDir)).toBeUndefined(); + expect( + dashboardStaticResponse("/dashboard/catalog/%2e%2e%2fsecret", dashboardDistDir), + ).toBeUndefined(); + expect(dashboardStaticResponse("/dashboard/catalog/key.js", dashboardDistDir)).toBeUndefined(); }); }); diff --git a/packages/core/test/remote-control-dispatch.test.ts b/packages/core/test/remote-control-dispatch.test.ts index 1bc0ca9a..888dd0c0 100644 --- a/packages/core/test/remote-control-dispatch.test.ts +++ b/packages/core/test/remote-control-dispatch.test.ts @@ -572,6 +572,11 @@ describe("dispatchRemoteCliRequest", () => { ], }, }); + expect( + JSON.parse(readFileSync(join(context.tempRoot, "remote-state", "caplets.lock.json"), "utf8")), + ).toMatchObject({ + entries: [expect.objectContaining({ id: "sample" })], + }); }); it("dispatches complete_cli using server-owned config", async () => { @@ -899,7 +904,8 @@ function currentHostAdministration(context: DispatchAdministrationContext) { projectConfigPath: context.projectConfigPath, authDir: context.authDir, globalCapletsRoot: context.globalCapletsRoot, - globalLockfilePath: context.globalLockfilePath, + globalLockfilePath: + context.globalLockfilePath ?? join(context.tempRoot, "remote-state", "caplets.lock.json"), }, activityLog: new DashboardActivityLog({ dir: join(context.tempRoot, "activity") }), version: "test-version", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b01e7bd..90434542 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.1 version: 4.3.1(vite@8.1.0(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/react-virtual': + specifier: ^3.14.5 + version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) astro: specifier: ^7.0.3 version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@26.0.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) @@ -3096,9 +3099,18 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/virtual-core@3.17.2': resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==} + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -9589,8 +9601,16 @@ snapshots: tailwindcss: 4.3.1 vite: 8.1.0(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + '@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@tanstack/virtual-core@3.17.2': {} + '@tanstack/virtual-core@3.17.3': {} + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3