diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs index 06775a10..a0f2d11c 100644 --- a/apps/desktop/preload.cjs +++ b/apps/desktop/preload.cjs @@ -3,7 +3,6 @@ const { contextBridge, ipcRenderer } = require("electron"); const api = { getState: () => ipcRenderer.invoke("openpets:get-state"), getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"), - getCatalogPage: (pageIndex) => ipcRenderer.invoke("openpets:get-catalog-page", pageIndex), getCodexPets: () => ipcRenderer.invoke("openpets:get-codex-pets"), updatePreferences: (patch) => ipcRenderer.invoke("openpets:update-preferences", patch), getLaunchAtLogin: () => ipcRenderer.invoke("openpets:get-launch-at-login"), @@ -37,10 +36,6 @@ let activePetManagerSelection = ""; let activePetManagerFilter = "all"; let activePetManagerItems = []; let activePetManagerDefaultId = ""; -let activePetManagerCatalogState = null; -let activePetManagerCodexState = null; -let activePetManagerAppState = null; -let activePetManagerLoadingPage = false; contextBridge.exposeInMainWorld("openPets", api); contextBridge.exposeInMainWorld("openpetsAgentSetup", agentSetupApi); @@ -617,9 +612,6 @@ async function copyText(text, resultId = "claude-action-result", successMessage async function renderPetManager(state) { const defaultPetId = state.preferences.defaultPetId; const [catalogState, codexState] = await Promise.all([api.getCatalog(), api.getCodexPets()]); - activePetManagerCatalogState = catalogState; - activePetManagerCodexState = codexState; - activePetManagerAppState = state; renderPetGallery(catalogState, codexState, state, defaultPetId); } @@ -638,12 +630,10 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { return; } - const sourceLabel = catalogState.source === "remote" ? catalogState.version === 3 ? "Live v3" : "Live" : catalogState.source === "fixture" ? "Fixture" : "Error"; + const sourceLabel = catalogState.source === "remote" ? "Live" : catalogState.source === "fixture" ? "Fixture" : "Error"; const codexLabel = codexState.error ? "Codex unavailable" : `${codexState.pets.length} Codex`; - const totalLabel = catalogState.v3 ? `${catalogState.pets.length}/${catalogState.v3.total} pets` : `${catalogState.pets.length} pets`; - status.textContent = catalogState.error ? `${sourceLabel}: ${catalogState.error} · ${codexLabel}` : `${sourceLabel}: ${totalLabel} · ${codexLabel}`; + status.textContent = catalogState.error ? `${sourceLabel}: ${catalogState.error} · ${codexLabel}` : `${sourceLabel}: ${catalogState.pets.length} pets · ${codexLabel}`; status.className = `pm-status-pill ${catalogState.error || codexState.error ? "error" : "success"}`; - updateCategoryFilterAvailability(catalogState); const pets = createPetManagerItems(catalogState, codexState, state, defaultPetId, defaultThumbnailSrc); activePetManagerItems = pets; activePetManagerDefaultId = defaultPetId; @@ -670,7 +660,6 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { const visiblePets = pets.filter((pet) => { if (activePetManagerFilter === "installed" && !pet.installed) return false; if (activePetManagerFilter === "codex" && !pet.codexPet && !pet.codexImported) return false; - if ((activePetManagerFilter === "western" || activePetManagerFilter === "asian") && pet.category !== activePetManagerFilter) return false; const haystack = `${pet.id} ${pet.displayName} ${pet.description}`.toLowerCase(); return haystack.includes(query); }); @@ -688,20 +677,6 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { grid.append(empty); } - if (catalogState.v3?.hasMore) { - const hint = document.createElement("div"); - hint.className = "pm-page-hint"; - hint.textContent = "Showing loaded pets only. Load more to continue browsing and filtering the full catalog."; - grid.append(hint); - const loadMore = document.createElement("button"); - loadMore.className = "pm-load-more"; - loadMore.type = "button"; - loadMore.textContent = activePetManagerLoadingPage ? "Loading…" : "Load more pets"; - loadMore.disabled = activePetManagerLoadingPage; - loadMore.addEventListener("click", () => { void loadNextCatalogPage(); }); - grid.append(loadMore); - } - const selected = visiblePets.find((pet) => pet.id === activePetManagerSelection) || visiblePets[0] || pets.find((pet) => pet.id === activePetManagerSelection) || pets[0]; if (selected) { renderPetDetail(detail, selected, defaultPetId); @@ -712,36 +687,6 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { render(); } -function updateCategoryFilterAvailability(catalogState) { - const v3Available = catalogState.version === 3 && catalogState.v3; - for (const id of ["western", "asian"]) { - const button = document.querySelector(`[data-pet-filter="${id}"]`); - if (!(button instanceof HTMLButtonElement)) continue; - button.hidden = !v3Available; - button.disabled = !v3Available; - } - if (!v3Available && (activePetManagerFilter === "western" || activePetManagerFilter === "asian")) activePetManagerFilter = "all"; -} - -async function loadNextCatalogPage() { - if (activePetManagerLoadingPage || !activePetManagerCatalogState?.v3) return; - const loaded = activePetManagerCatalogState.v3.loadedPages || []; - const nextPage = loaded.length ? Math.max(...loaded) + 1 : 0; - activePetManagerLoadingPage = true; - try { - const catalogState = await api.getCatalogPage(nextPage); - activePetManagerCatalogState = catalogState; - activePetManagerLoadingPage = false; - if (activePetManagerCodexState && activePetManagerAppState) { - renderPetGallery(catalogState, activePetManagerCodexState, activePetManagerAppState, activePetManagerAppState.preferences.defaultPetId); - } - } catch (error) { - renderCaughtError(error); - } finally { - activePetManagerLoadingPage = false; - } -} - function createPetManagerItems(catalogState, codexState, state, defaultPetId, defaultThumbnailSrc) { const installedById = new Map(state.pets.installed.map((pet) => [pet.id, pet])); const codexById = new Map(codexState.pets.map((pet) => [pet.id, pet])); @@ -769,11 +714,8 @@ function createPetManagerItems(catalogState, codexState, state, defaultPetId, de } function createPetManagerItem(id, displayName, description, installed, catalogPet, codexPet, codexImported, defaultPetId, defaultThumbnailSrc) { - const catalogPreview = catalogPet?.thumbnail || catalogPet?.preview || ""; - const installedPreview = installed?.source?.kind !== "codex" && isAllowedCatalogPreview(installed?.source?.preview) ? installed.source.preview : ""; - const preview = codexPet?.preview || (catalogPet && isAllowedCatalogPreview(catalogPreview) ? catalogPreview : "") || installedPreview; + const preview = codexPet?.preview || (catalogPet && isAllowedCatalogPreview(catalogPet.preview) ? catalogPet.preview : ""); const usesThumbnail = Boolean(installed?.builtIn && defaultThumbnailSrc); - const previewIsThumbnail = usesThumbnail || Boolean(catalogPet?.thumbnail) || Boolean(installedPreview && installed?.source?.catalogVersion === 3 && preview === installedPreview); return { id, displayName, @@ -783,9 +725,8 @@ function createPetManagerItem(id, displayName, description, installed, catalogPe codexPet, codexImported, previewSrc: usesThumbnail ? defaultThumbnailSrc : preview, - previewIsSpriteSheet: !previewIsThumbnail, + previewIsSpriteSheet: !usesThumbnail, isDefault: id === defaultPetId, - category: catalogPet?.category || "", protected: Boolean(installed?.protected), broken: Boolean(installed?.broken), brokenReason: installed?.brokenReason || "", diff --git a/apps/desktop/src/app-state.ts b/apps/desktop/src/app-state.ts index 75907736..a776e1c3 100644 --- a/apps/desktop/src/app-state.ts +++ b/apps/desktop/src/app-state.ts @@ -17,7 +17,7 @@ export interface InstalledPetState { readonly installed: boolean; readonly source?: { readonly kind?: "catalog"; - readonly catalogVersion: 2 | 3; + readonly catalogVersion: 2; readonly zip: string; readonly preview: string; } | { @@ -405,11 +405,11 @@ function normalizeSource(value: unknown): InstalledPetState["source"] | undefine return { kind: "codex", path: value.path }; } - if ((value.catalogVersion !== 2 && value.catalogVersion !== 3) || typeof value.zip !== "string" || typeof value.preview !== "string") return undefined; + if (value.catalogVersion !== 2 || typeof value.zip !== "string" || typeof value.preview !== "string") return undefined; return { kind: "catalog", - catalogVersion: value.catalogVersion, + catalogVersion: 2, zip: value.zip, preview: value.preview, }; diff --git a/apps/desktop/src/catalog-validation.ts b/apps/desktop/src/catalog-validation.ts index 0611b770..6562f72b 100644 --- a/apps/desktop/src/catalog-validation.ts +++ b/apps/desktop/src/catalog-validation.ts @@ -12,41 +12,6 @@ export interface CatalogPetV2 { readonly zip: string; } -export interface CatalogV3Index { - readonly version: 3; - readonly generatedAt: string; - readonly total: number; - readonly pageSize: number; - readonly filters: { readonly categories: readonly CatalogV3Category[] }; - readonly pages: readonly string[]; -} - -export interface CatalogV3Category { - readonly id: "western" | "asian"; - readonly label: string; - readonly count: number; -} - -export interface CatalogV3Page { - readonly version: 3; - readonly generatedAt: string; - readonly page: number; - readonly pageSize: number; - readonly pets: readonly CatalogPetV3[]; -} - -export interface CatalogPetV3 { - readonly id: string; - readonly displayName: string; - readonly description: string; - readonly thumbnail: string; - readonly preview?: string; - readonly spritesheet?: string; - readonly zip: string; - readonly category: "western" | "asian"; - readonly subcategory?: string; -} - export function validateCatalogV2(value: unknown): CatalogV2 { if (!isRecord(value)) throw new Error("Catalog must be an object."); if (value.version !== 2) throw new Error("Catalog version must be 2."); @@ -64,32 +29,6 @@ export function validateCatalogV2(value: unknown): CatalogV2 { }; } -export function validateCatalogV3Index(value: unknown): CatalogV3Index { - if (!isRecord(value)) throw new Error("Catalog v3 index must be an object."); - if (value.version !== 3) throw new Error("Catalog index version must be 3."); - if (typeof value.generatedAt !== "string" || Number.isNaN(Date.parse(value.generatedAt))) throw new Error("Catalog generatedAt must be a valid date string."); - if (!Number.isSafeInteger(value.total) || value.total < 0 || value.total > 20_000) throw new Error("Catalog total is invalid."); - if (!Number.isSafeInteger(value.pageSize) || value.pageSize <= 0 || value.pageSize > 200) throw new Error("Catalog pageSize is invalid."); - if (!Array.isArray(value.pages) || value.pages.length > 100) throw new Error("Catalog pages are invalid."); - const pages = value.pages.map((page, index) => validateCatalogPageUrl(page, index)); - const filters = validateCatalogV3Filters(value.filters); - if (pages.length !== Math.ceil(value.total / value.pageSize)) throw new Error("Catalog page count does not match total/pageSize."); - if (filters.categories.reduce((total, category) => total + category.count, 0) !== value.total) throw new Error("Catalog category counts do not match total."); - return { version: 3, generatedAt: value.generatedAt, total: value.total, pageSize: value.pageSize, filters, pages }; -} - -export function validateCatalogV3Page(value: unknown, expectedPage: number, expectedPageSize: number): CatalogV3Page { - if (!isRecord(value)) throw new Error("Catalog v3 page must be an object."); - if (value.version !== 3) throw new Error("Catalog page version must be 3."); - if (typeof value.generatedAt !== "string" || Number.isNaN(Date.parse(value.generatedAt))) throw new Error("Catalog page generatedAt must be a valid date string."); - if (value.page !== expectedPage) throw new Error("Catalog page index does not match requested page."); - if (value.pageSize !== expectedPageSize) throw new Error("Catalog page size does not match index."); - if (!Array.isArray(value.pets) || value.pets.length > expectedPageSize) throw new Error("Catalog page pets are invalid."); - const ids = new Set(); - const pets = value.pets.map((pet) => validateCatalogPetV3(pet, ids)); - return { version: 3, generatedAt: value.generatedAt, page: expectedPage, pageSize: expectedPageSize, pets }; -} - function validateCatalogPet(value: unknown, ids: Set): CatalogPetV2 { if (!isRecord(value)) throw new Error("Catalog pet must be an object."); const id = validateId(value.id); @@ -106,26 +45,6 @@ function validateCatalogPet(value: unknown, ids: Set): CatalogPetV2 { }; } -function validateCatalogPetV3(value: unknown, ids: Set): CatalogPetV3 { - if (!isRecord(value)) throw new Error("Catalog v3 pet must be an object."); - const id = validateId(value.id); - if (ids.has(id)) throw new Error(`Duplicate catalog v3 pet id on page: ${id}`); - ids.add(id); - const category = validateCategory(value.category); - const pet: CatalogPetV3 = { - id, - displayName: validateString(value.displayName, "displayName", 120), - description: validateString(value.description, "description", 500), - thumbnail: validateCatalogUrl(value.thumbnail, "thumbnail"), - zip: validateCatalogUrl(value.zip, "zip"), - category, - }; - const preview = validateOptionalCatalogUrl(value.preview, "preview"); - const spritesheet = validateOptionalCatalogUrl(value.spritesheet, "spritesheet"); - const subcategory = typeof value.subcategory === "string" ? validateString(value.subcategory, "subcategory", 80) : undefined; - return { ...pet, ...(preview ? { preview } : {}), ...(spritesheet ? { spritesheet } : {}), ...(subcategory ? { subcategory } : {}) }; -} - function validateId(value: unknown): string { if (typeof value !== "string") throw new Error("Catalog pet id must be a string."); if (value === "builtin") throw new Error("Catalog pet id 'builtin' is reserved."); @@ -139,7 +58,7 @@ function validateString(value: unknown, field: string, maxLength: number): strin return value; } -function validateCatalogUrl(value: unknown, field: "preview" | "zip" | "thumbnail" | "spritesheet"): string { +function validateCatalogUrl(value: unknown, field: "preview" | "zip"): string { const raw = validateString(value, field, 2048); const url = new URL(raw); @@ -147,9 +66,8 @@ function validateCatalogUrl(value: unknown, field: "preview" | "zip" | "thumbnai if (url.username || url.password) throw new Error(`${field} URL cannot include credentials.`); if (url.port) throw new Error(`${field} URL cannot include a custom port.`); - if (field === "preview" || field === "thumbnail" || field === "spritesheet") { + if (field === "preview") { if (url.hostname !== "openpets.dev" || !url.pathname.startsWith("/pets/")) throw new Error("Preview URL host/path is not allowed."); - if (!url.pathname.endsWith(".webp")) throw new Error(`${field} URL must be a WebP image.`); } else if (url.hostname !== "zip.openpets.dev" || !url.pathname.startsWith("/pets/")) { throw new Error("Zip URL host/path is not allowed."); } @@ -157,41 +75,6 @@ function validateCatalogUrl(value: unknown, field: "preview" | "zip" | "thumbnai return url.toString(); } -function validateOptionalCatalogUrl(value: unknown, field: "preview" | "spritesheet"): string | undefined { - if (value === undefined) return undefined; - return validateCatalogUrl(value, field); -} - -function validateCatalogV3Filters(value: unknown): { readonly categories: readonly CatalogV3Category[] } { - if (!isRecord(value) || !Array.isArray(value.categories)) throw new Error("Catalog v3 filters are invalid."); - const categories = value.categories.map(validateCatalogV3Category); - const ids = new Set(categories.map((category) => category.id)); - if (ids.size !== categories.length || !ids.has("western") || !ids.has("asian")) throw new Error("Catalog v3 category filters are incomplete or duplicated."); - return { categories }; -} - -function validateCatalogV3Category(value: unknown): CatalogV3Category { - if (!isRecord(value)) throw new Error("Catalog v3 category must be an object."); - const id = validateCategory(value.id); - if (typeof value.label !== "string" || value.label.length > 40) throw new Error("Catalog v3 category label is invalid."); - if (!Number.isSafeInteger(value.count) || value.count < 0) throw new Error("Catalog v3 category count is invalid."); - return { id, label: value.label, count: value.count }; -} - -function validateCatalogPageUrl(value: unknown, index: number): string { - const raw = validateString(value, "page", 2048); - const url = new URL(raw); - if (url.protocol !== "https:" || url.hostname !== "openpets.dev" || url.port || url.username || url.password) throw new Error("Catalog page URL origin is not allowed."); - if (url.search || url.hash) throw new Error("Catalog page URL cannot include query or hash."); - if (url.pathname !== `/pets/catalog.v3/page-${String(index).padStart(3, "0")}.json`) throw new Error("Catalog page URL path is not allowed."); - return url.toString(); -} - -function validateCategory(value: unknown): "western" | "asian" { - if (value === "western" || value === "asian") return value; - throw new Error("Catalog pet category is invalid."); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/apps/desktop/src/catalog.ts b/apps/desktop/src/catalog.ts index c7187ac1..d7e0590e 100644 --- a/apps/desktop/src/catalog.ts +++ b/apps/desktop/src/catalog.ts @@ -3,68 +3,28 @@ import { join } from "node:path"; import { app } from "electron"; -import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, type CatalogPetV2, type CatalogPetV3, type CatalogV2, type CatalogV3Index, type CatalogV3Page } from "./catalog-validation.js"; +import { validateCatalogV2, type CatalogPetV2, type CatalogV2 } from "./catalog-validation.js"; export const catalogUrl = "https://openpets.dev/pets/catalog.v2.json"; -export const catalogV3Url = "https://openpets.dev/pets/catalog.v3.json"; const fixtureRelativePath = "catalog.v2.fixture.json"; const maxCatalogBytes = 1_000_000; -const maxCatalogV3IndexBytes = 256 * 1024; -const maxCatalogV3PageBytes = 256 * 1024; const fetchTimeoutMs = 5_000; export interface CatalogUiState { readonly source: "remote" | "fixture" | "error"; - readonly version: 2 | 3; - readonly pets: readonly CatalogUiPet[]; + readonly pets: readonly CatalogPetV2[]; readonly generatedAt?: string; readonly error?: string; - readonly v3?: CatalogV3UiState; } -export type CatalogUiPet = CatalogPetV2 | CatalogPetV3; - -export interface CatalogV3UiState { - readonly total: number; - readonly pageSize: number; - readonly loadedPages: readonly number[]; - readonly hasMore: boolean; - readonly filters: CatalogV3Index["filters"]; -} - -const v3PageCache = new Map(); -let v3IndexCache: CatalogV3Index | null = null; - export async function getCatalogUiState(): Promise { - const v3 = await tryLoadRemoteCatalogV3FirstPage(); - - if (v3.ok) { - const loadedPages = [...new Set([...v3PageCache.keys()])].sort((left, right) => left - right); - const pets = loadedPages.flatMap((loadedPage) => v3PageCache.get(loadedPage)?.pets ?? []); - return { - source: "remote", - version: 3, - pets, - generatedAt: v3.index.generatedAt, - v3: { - total: v3.index.total, - pageSize: v3.index.pageSize, - loadedPages, - hasMore: loadedPages.length < v3.index.pages.length, - filters: v3.index.filters, - }, - }; - } - const remote = await tryLoadRemoteCatalog(); if (remote.ok) { return { source: "remote", - version: 2, pets: remote.catalog.pets, generatedAt: remote.catalog.generatedAt, - error: `Catalog v3 unavailable: ${v3.error}`, }; } @@ -73,65 +33,19 @@ export async function getCatalogUiState(): Promise { if (fixture.ok) { return { source: "fixture", - version: 2, pets: fixture.catalog.pets, generatedAt: fixture.catalog.generatedAt, - error: `Catalog v3 unavailable: ${v3.error}. Live catalog unavailable: ${remote.error}`, + error: `Live catalog unavailable: ${remote.error}`, }; } return { source: "error", - version: 2, pets: [], - error: `Catalog v3 unavailable: ${v3.error}. Live catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`, + error: `Live catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`, }; } -export async function getCatalogPageUiState(pageIndex: number): Promise { - if (!Number.isSafeInteger(pageIndex) || pageIndex < 0) throw new Error("Catalog page index is invalid."); - const index = await getCatalogV3Index(); - const page = await getCatalogV3Page(index, pageIndex); - const loadedPages = [...new Set([...v3PageCache.keys()])].sort((left, right) => left - right); - const pets = loadedPages.flatMap((loadedPage) => v3PageCache.get(loadedPage)?.pets ?? []); - return { - source: "remote", - version: 3, - pets, - generatedAt: index.generatedAt, - v3: { - total: index.total, - pageSize: index.pageSize, - loadedPages, - hasMore: loadedPages.length < index.pages.length, - filters: index.filters, - }, - }; -} - -export async function getCatalogPetById(petId: string): Promise { - const v3 = await tryLoadRemoteCatalogV3FirstPage(); - if (v3.ok) { - const cached = findCachedV3Pet(petId); - if (cached) return cached; - try { - for (let pageIndex = 0; pageIndex < v3.index.pages.length; pageIndex += 1) { - const page = await getCatalogV3Page(v3.index, pageIndex); - const pet = page.pets.find((candidate) => candidate.id === petId); - if (pet) return pet; - } - } catch { - // Fall back to the compatibility catalog if any later v3 page is unavailable or invalid. - } - } - const remote = await tryLoadRemoteCatalog(); - const fixture = remote.ok ? null : await tryLoadFixtureCatalog(); - const fallback = remote.ok ? remote.catalog : fixture?.ok ? fixture.catalog : null; - const pet = fallback?.pets.find((candidate) => candidate.id === petId); - if (!pet) throw new Error(`Pet is not available in the validated catalog: ${petId}`); - return pet; -} - async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); @@ -155,53 +69,6 @@ async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly cat } } -async function tryLoadRemoteCatalogV3FirstPage(): Promise<{ readonly ok: true; readonly index: CatalogV3Index; readonly page: CatalogV3Page } | { readonly ok: false; readonly error: string }> { - try { - const index = await getCatalogV3Index(); - const page = await getCatalogV3Page(index, 0); - return { ok: true, index, page }; - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; - } -} - -async function getCatalogV3Index(): Promise { - if (v3IndexCache) return v3IndexCache; - const response = await fetchBounded(catalogV3Url, maxCatalogV3IndexBytes); - validateCatalogV3IndexEndpoint(response.url); - v3IndexCache = validateCatalogV3Index(JSON.parse(response.text) as unknown); - return v3IndexCache; -} - -async function getCatalogV3Page(index: CatalogV3Index, pageIndex: number): Promise { - const cached = v3PageCache.get(pageIndex); - if (cached) return cached; - const pageUrl = index.pages[pageIndex]; - if (!pageUrl) throw new Error(`Catalog page is unavailable: ${pageIndex}`); - const response = await fetchBounded(pageUrl, maxCatalogV3PageBytes); - if (response.url !== pageUrl) throw new Error("Catalog page final URL changed."); - const page = validateCatalogV3Page(JSON.parse(response.text) as unknown, pageIndex, index.pageSize); - for (const cachedPage of v3PageCache.values()) { - for (const pet of page.pets) { - if (cachedPage.pets.some((cachedPet) => cachedPet.id === pet.id)) throw new Error(`Duplicate catalog v3 pet id across pages: ${pet.id}`); - } - } - v3PageCache.set(pageIndex, page); - return page; -} - -async function fetchBounded(url: string, maxBytes: number): Promise<{ readonly url: string; readonly text: string }> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); - try { - const response = await fetch(url, { signal: controller.signal, redirect: "error", credentials: "omit" }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return { url: response.url, text: await readLimitedResponse(response, maxBytes) }; - } finally { - clearTimeout(timeout); - } -} - async function tryLoadFixtureCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> { try { return { ok: true, catalog: validateCatalogV2(await loadFixtureCatalog()) }; @@ -215,7 +82,7 @@ async function loadFixtureCatalog(): Promise { return JSON.parse(await readFile(fixturePath, "utf8")) as unknown; } -async function readLimitedResponse(response: Response, maxBytes = maxCatalogBytes): Promise { +async function readLimitedResponse(response: Response): Promise { const reader = response.body?.getReader(); if (!reader) throw new Error("Catalog response body is unavailable for bounded reading."); @@ -226,7 +93,7 @@ async function readLimitedResponse(response: Response, maxBytes = maxCatalogByte const { done, value } = await reader.read(); if (done) break; total += value.byteLength; - if (total > maxBytes) throw new Error("Catalog response is too large."); + if (total > maxCatalogBytes) throw new Error("Catalog response is too large."); chunks.push(value); } @@ -247,16 +114,3 @@ function validateCatalogEndpoint(value: string): void { const url = new URL(value); if (url.href !== catalogUrl) throw new Error("Catalog final URL is not allowed."); } - -function validateCatalogV3IndexEndpoint(value: string): void { - const url = new URL(value); - if (url.href !== catalogV3Url) throw new Error("Catalog v3 final URL is not allowed."); -} - -function findCachedV3Pet(petId: string): CatalogPetV3 | undefined { - for (const page of v3PageCache.values()) { - const pet = page.pets.find((candidate) => candidate.id === petId); - if (pet) return pet; - } - return undefined; -} diff --git a/apps/desktop/src/check-catalog-fixture.ts b/apps/desktop/src/check-catalog-fixture.ts index 8f6a2e78..618157e6 100644 --- a/apps/desktop/src/check-catalog-fixture.ts +++ b/apps/desktop/src/check-catalog-fixture.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; -import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page } from "./catalog-validation.js"; +import { validateCatalogV2 } from "./catalog-validation.js"; const fixture = JSON.parse(await readFile("catalog.v2.fixture.json", "utf8")) as unknown; validateCatalogV2(fixture); @@ -17,48 +17,6 @@ for (const invalidCase of invalidCases) { assertRejectsCatalog(invalidCase); } -const v3Index = validateCatalogV3Index({ - version: 3, - generatedAt: new Date().toISOString(), - total: 1, - pageSize: 100, - filters: { categories: [{ id: "western", label: "Western", count: 1 }, { id: "asian", label: "Asian", count: 0 }] }, - pages: ["https://openpets.dev/pets/catalog.v3/page-000.json"], -}); - -validateCatalogV3Page({ - version: 3, - generatedAt: v3Index.generatedAt, - page: 0, - pageSize: 100, - pets: [{ - id: "snoopy", - displayName: "Snoopy", - description: "A tiny beagle.", - thumbnail: "https://openpets.dev/pets/snoopy-23e05847/thumb.webp", - spritesheet: "https://openpets.dev/pets/snoopy-23e05847/spritesheet.webp", - zip: "https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip", - category: "western", - subcategory: "cartoons", - }], -}, 0, 100); - -assertRejectsCatalogV3Page({ - version: 3, - generatedAt: new Date().toISOString(), - page: 0, - pageSize: 100, - pets: [{ id: "bad", displayName: "Bad", description: "", thumbnail: "https://openpets.dev/pets/bad/spritesheet.png", zip: "https://zip.openpets.dev/pets/bad/bad.zip", category: "western" }], -}); - -assertRejectsCatalogV3Page({ - version: 3, - generatedAt: new Date().toISOString(), - page: 0, - pageSize: 100, - pets: [{ id: "bad", displayName: "Bad", description: "", thumbnail: "https://openpets.dev/pets/bad/thumb.webp", zip: "https://zip.openpets.dev/pets/bad/bad.zip", category: "unknown" }], -}); - console.log("Catalog fixture validation passed."); function assertRejectsCatalog(value: unknown): void { @@ -70,13 +28,3 @@ function assertRejectsCatalog(value: unknown): void { throw new Error("Invalid catalog fixture case was accepted."); } - -function assertRejectsCatalogV3Page(value: unknown): void { - try { - validateCatalogV3Page(value, 0, 100); - } catch { - return; - } - - throw new Error("Invalid catalog v3 page case was accepted."); -} diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 2dc948dc..1eef05dc 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -159,11 +159,6 @@ assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data:; st assert.doesNotMatch(windowsSource, /data-default-pet-sprite-src|createAssetDataUrl\("default-pet-spritesheet\.webp"/, "Pet Manager must not embed the large default spritesheet into the task-window data URL."); assert.match(windowsSource, /default-pet-thumbnail\.png/, "Pet Manager must use the small bundled default pet thumbnail for built-in preview."); assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data: https://openpets.dev; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"`), "Pet Manager image CSP must stay scoped to data URLs and openpets.dev catalog previews."); -assert.match(windowsSource, /data-pet-filter="codex"/, "Pet Manager must preserve the Codex filter."); -assert.match(windowsSource, /data-pet-filter="western"/, "Pet Manager must expose a Western catalog filter when v3 metadata is available."); -assert.match(windowsSource, /data-pet-filter="asian"/, "Pet Manager must expose an Asian catalog filter when v3 metadata is available."); -assert.match(preloadSource, /api\.getCatalogPage/, "Pet Manager preload must support paged catalog loading."); -assert.match(preloadSource, /catalogPet\?\.thumbnail/, "Pet Manager cards must prefer lightweight v3 thumbnails over full spritesheets."); assert.match(windowsSource, /petManagerWindowWidth\s*=\s*1160/, "Pet Manager should use the approved wider 1160px default window width."); assert.match(windowsSource, /petManagerWindowHeight\s*=\s*780/, "Pet Manager should use the approved taller 780px default window height."); assert.match(agentSetupHtmlSource, /content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"/, "Agent Setup image CSP must stay data-only for the bundled logo asset."); diff --git a/apps/desktop/src/pet-installation.ts b/apps/desktop/src/pet-installation.ts index 7ef8514b..6ba375e9 100644 --- a/apps/desktop/src/pet-installation.ts +++ b/apps/desktop/src/pet-installation.ts @@ -8,8 +8,8 @@ import yauzl from "yauzl"; import type { Entry, ZipFile } from "yauzl"; import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type OpenPetsStateV1 } from "./app-state.js"; -import { getCatalogPetById } from "./catalog.js"; -import type { CatalogPetV2, CatalogPetV3 } from "./catalog-validation.js"; +import { getCatalogUiState } from "./catalog.js"; +import type { CatalogPetV2 } from "./catalog-validation.js"; import { builtInPet } from "./built-in-pet.js"; import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./pet-paths.js"; import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js"; @@ -51,9 +51,9 @@ export async function installPet(petId: string): Promise { displayName: catalogPet.displayName, description: catalogPet.description, source: { - catalogVersion: "thumbnail" in catalogPet ? 3 : 2, + catalogVersion: 2, zip: catalogPet.zip, - preview: "preview" in catalogPet && catalogPet.preview ? catalogPet.preview : "thumbnail" in catalogPet ? catalogPet.thumbnail : catalogPet.preview, + preview: catalogPet.preview, }, }); } catch (error) { @@ -106,8 +106,13 @@ export async function withPetOperation(key: string, callback: () => Promise { - return getCatalogPetById(petId); +async function getCatalogPet(petId: string): Promise { + const catalog = await getCatalogUiState(); + const pet = catalog.pets.find((candidate) => candidate.id === petId); + if (!pet) { + throw new Error(`Pet is not available in the validated catalog: ${petId}`); + } + return pet; } async function downloadPetZip(zipUrl: string): Promise { diff --git a/apps/desktop/src/windows.ts b/apps/desktop/src/windows.ts index 48db3dab..b0affb0e 100644 --- a/apps/desktop/src/windows.ts +++ b/apps/desktop/src/windows.ts @@ -6,7 +6,7 @@ import { app, BrowserWindow, ipcMain, type IpcMainInvokeEvent } from "electron"; import { getAgentSetupSnapshot, runAgentSetupAction, updateAgentSetupCommandPaths } from "./agent-setup.js"; import { refreshAgentPetContent } from "./agent-pet-controller.js"; import { completeOnboarding, getAppStateSnapshot, normalizePetScale, petScaleOptions, updatePreferences } from "./app-state.js"; -import { getCatalogPageUiState, getCatalogUiState } from "./catalog.js"; +import { getCatalogUiState } from "./catalog.js"; import { getCodexPetsUiState, importCodexPet } from "./codex-pets.js"; import { refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-pet-controller.js"; import { installPet, removePet, setDefaultInstalledPet } from "./pet-installation.js"; @@ -99,12 +99,6 @@ export function installInternalUiHandlers(): void { return getCatalogUiState(); }); - ipcMain.handle("openpets:get-catalog-page", async (event, pageIndex: unknown) => { - assertAllowedSender(event, ["pet-manager"]); - if (!Number.isSafeInteger(pageIndex) || pageIndex < 0) throw new Error("Invalid catalog page index."); - return getCatalogPageUiState(pageIndex); - }); - ipcMain.handle("openpets:get-codex-pets", async (event) => { assertAllowedSender(event, ["pet-manager"]); return getCodexPetsUiState(); @@ -385,8 +379,6 @@ function createPetManagerHtml(definition: TaskWindowDefinition): string { - - Loading… @@ -1032,9 +1024,6 @@ function createTaskWindowStyles(): string { body[data-openpets-view="pet-manager"] .pm-detail-actions button:disabled { opacity: 1; cursor: default; background: linear-gradient(180deg, #ecf5ff, #dbeafe); color: #176df2; box-shadow: inset 0 1px 0 rgba(255,255,255,0.9); } body[data-openpets-view="pet-manager"] .pm-detail-actions button:only-child { grid-column: 1 / -1; } body[data-openpets-view="pet-manager"] .pm-empty-state { grid-column: 1 / -1; padding: 28px; text-align: center; color: #667694; border: 1px dashed rgba(126, 161, 210, 0.48); border-radius: 18px; background: rgba(255,255,255,0.5); } - body[data-openpets-view="pet-manager"] .pm-load-more { grid-column: 1 / -1; border: 1px solid rgba(59, 130, 246, 0.26); border-radius: 16px; background: rgba(255,255,255,0.78); color: #1d4ed8; font-weight: 800; padding: 12px 14px; cursor: pointer; } - body[data-openpets-view="pet-manager"] .pm-load-more:disabled { cursor: wait; opacity: 0.66; } - body[data-openpets-view="pet-manager"] .pm-page-hint { grid-column: 1 / -1; color: #667694; font-size: 12px; text-align: center; } body[data-openpets-view="pet-manager"] [data-error] { position: fixed; left: 18px; right: 18px; bottom: 8px; margin: 0; color: #b91c1c; pointer-events: none; } @keyframes pm-idle-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } } @keyframes pm-sprite-idle { from { background-position: 0 0; } to { background-position: 85.714% 0; } } diff --git a/docs/phases/phase-20-scalable-pet-catalog.md b/docs/phases/phase-20-scalable-pet-catalog.md deleted file mode 100644 index 965c0fe2..00000000 --- a/docs/phases/phase-20-scalable-pet-catalog.md +++ /dev/null @@ -1,296 +0,0 @@ -# Phase 20: Scalable Pet Catalog and Lightweight Picker Images - -## Goal - -Scale the public pet catalog and desktop Pet Manager beyond 1,000 pets without loading full spritesheets for every visible card. - -The clean solution is to move lightweight preview generation into the `web/` catalog pipeline and have desktop consume paginated catalog metadata with small thumbnail assets. - -## Non-goals - -- Do not change Codex/local pet discovery, import, preview inlining, or `~/.codex/pets` behavior in this phase. -- Do not change installed pet runtime rendering; installed pets still use `spritesheet.webp` after install. -- Do not remove or break `catalog.v2.json`; keep it available for older desktop clients. -- Do not make the Electron renderer construct untrusted image URLs independently. -- Do not broaden desktop CSP beyond the exact image origins needed. - -## Problem - -The current desktop Pet Manager reads `catalog.v2.json`, then uses each pet's `preview` URL as a card preview. Today `web/scripts/*` writes `preview` as the full spritesheet: - -```js -preview: `${PUBLIC_BASE_URL}${pet.spritesheetPath}` -``` - -That means the picker can request hundreds or thousands of full files like: - -```text -https://openpets.dev/pets//spritesheet.webp -``` - -With a 1,000+ pet catalog, this creates excessive network, decode, memory, and layout work. Desktop-side lazy loading helps, but it does not solve the root issue: gallery cards need tiny thumbnails, not full runtime spritesheets. - -## Desired outcome - -- Public catalog supports 1,000+ pets without a huge single JSON payload. -- Pet Manager initially loads only a small page of metadata and small thumbnails. -- Full spritesheets are fetched only when needed for install/runtime or, optionally, selected-pet detail preview. -- Pet Manager keeps existing filters, including `Codex`, and adds the same high-level public catalog filters already used on the web: `Western` and `Asian`. -- Existing desktop clients can continue using `catalog.v2.json`. -- Codex/local pet behavior remains unchanged. - -## Proposed asset model - -For every public catalog pet under `web/public/pets//`, generate and publish: - -```text -spritesheet.webp # existing full runtime/install asset -thumb.webp # new tiny static thumbnail for gallery cards -preview.webp # optional small animated/detail preview, if cheap to generate -.zip # existing install package, served from zip.openpets.dev -``` - -Recommended budgets: - -- `thumb.webp`: 96-160px static image, target < 10-20 KB. -- `preview.webp`: optional short idle animation or selected-detail image, target < 50-100 KB. -- `spritesheet.webp`: unchanged; used for installs/runtime, not bulk gallery cards. - -If `preview.webp` is not generated in the first implementation, desktop can use `thumbnail` in cards and reserve `spritesheet` only for selected detail/install paths. - -## Proposed catalog model - -Keep `public/pets/catalog.v2.json` unchanged for compatibility. - -Add `public/pets/catalog.v3.json` as an index: - -```json -{ - "version": 3, - "generatedAt": "2026-05-12T00:00:00.000Z", - "total": 2500, - "pageSize": 100, - "filters": { - "categories": [ - { "id": "western", "label": "Western", "count": 1250 }, - { "id": "asian", "label": "Asian", "count": 1250 } - ] - }, - "pages": [ - "https://openpets.dev/pets/catalog.v3/page-000.json", - "https://openpets.dev/pets/catalog.v3/page-001.json" - ] -} -``` - -Add paginated page files under `public/pets/catalog.v3/`: - -```json -{ - "version": 3, - "page": 0, - "pageSize": 100, - "pets": [ - { - "id": "snoopy", - "displayName": "Snoopy", - "description": "A tiny black-and-white beagle with a red collar for calm coding sessions.", - "thumbnail": "https://openpets.dev/pets/snoopy-23e05847/thumb.webp", - "preview": "https://openpets.dev/pets/snoopy-23e05847/preview.webp", - "spritesheet": "https://openpets.dev/pets/snoopy-23e05847/spritesheet.webp", - "zip": "https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip", - "category": "western", - "subcategory": "cartoons" - } - ] -} -``` - -Fields: - -- `thumbnail`: required for v3 catalog pets. -- `preview`: optional; desktop must tolerate missing/failed preview. -- `spritesheet`: optional for gallery use, but useful for detail/runtime preview if explicitly selected. -- `zip`: required for installation. -- `category`: required for v3 public catalog pets and currently limited to `western` or `asian`. -- `subcategory`: optional; preserve existing web metadata where present. - -V3 index invariants: - -- Maximum index response size: 256 KB. -- Maximum page response size: 256 KB. -- Default page size: 100 pets. -- Maximum page size: 200 pets. -- Maximum page count: 100 pages for this phase. -- `total` must equal the sum of pets across all pages during generation. -- Pet IDs must be unique across all pages. -- Page URLs must match `https://openpets.dev/pets/catalog.v3/page-<3 digit>.json`. -- Deploy ordering must publish page files before publishing the index that references them. - -## Public catalog filters - -The web app already groups public pets into two top-level filters: `Western` and `Asian`. V3 should carry this as canonical metadata so desktop does not infer categories from names, slugs, descriptions, or paths. - -Filter contract: - -- `category` is required for every v3 public catalog pet. -- Allowed initial values are exactly: - - `western` - - `asian` -- Desktop labels these as `Western` and `Asian`. -- Desktop keeps existing filters and adds category filters: `All`, `Installed`, `Codex`, `Western`, and `Asian`. -- `Codex` filter behavior must stay unchanged from the current Pet Manager. -- If a v3 pet has a missing/unknown category, validation should reject the page or mark the pet unavailable rather than guessing. -- v2 fallback does not provide reliable category filtering; when using v2 fallback, desktop should hide `Western`/`Asian` filters or show them disabled. -- Codex/local-only pets are not part of this category filter contract in this phase. If shown in the same grid, they continue to appear under the existing `Codex` filter and under `All`/`Installed` when applicable, but not under `Western`/`Asian` unless they correspond to a catalog pet with v3 category metadata. - -Category source rules: - -- `web/scripts/import-reviewed-pets.js` already validates reviewed-pet `category`; v3 must preserve it. -- `web/scripts/sync-pets.js` must preserve category from an existing manifest entry when present. -- `web/scripts/sync-local-pets.js` must preserve category from the existing generated/manifest entry when present. -- Any pet still missing `western`/`asian` after preservation is excluded from v3 and logged, while v2 remains unchanged for compatibility. -- Generation must not infer category from names, slugs, descriptions, upstream source, or folder paths. - -## Web implementation plan - -Update all public catalog writers that currently emit `catalog.v2.json`: - -- `web/scripts/sync-pets.js` -- `web/scripts/import-reviewed-pets.js` -- `web/scripts/sync-local-pets.js` - -Tasks: - -1. Add shared helpers for catalog asset paths and v3 output shape. -2. Generate `thumb.webp` for each public catalog pet if missing or stale. -3. Optionally generate `preview.webp` after `thumb.webp` is stable. -4. Continue writing `catalog.v2.json` as a backward-compatible subset if the public catalog grows beyond the existing v2 desktop validation limit. -5. Write `catalog.v3.json` plus `catalog.v3/page-XXX.json` files. -6. Keep zip URLs on `zip.openpets.dev` unchanged. -7. Include the existing web category metadata in every v3 pet as `category: "western" | "asian"`. -8. Add validation that every v3 page item has safe `id`, `thumbnail`, `zip`, and known `category` fields. -9. Add category counts to the v3 index so desktop can show correct filters before every page is loaded. - -Thumbnail generation options: - -- Preferred: use `sharp` in the web workspace to crop/extract the universal spritesheet idle first frame from the 8-column by 9-row spritesheet and resize to a small static WebP. -- Fallback: if sprite-frame extraction is unreliable for a pet, create a small resized/cropped static thumbnail from the top-left/idle frame area and log the fallback. -- Stale detection should compare `thumb.webp` mtime to `spritesheet.webp`; regenerate when the spritesheet is newer. -- Generation should fail or loudly warn if thumbnails exceed the agreed byte/dimension budget. - -## Desktop implementation plan - -Add a new desktop catalog path while keeping v2 fallback: - -1. Fetch `https://openpets.dev/pets/catalog.v3.json` first. -2. Validate the index response size and exact final URL. -3. Fetch the first page only on initial Pet Manager open. -4. Load additional pages on scroll, explicit "Load more", or search pagination. -5. Render card images from `thumbnail`, not `preview`/`spritesheet`. -6. Preserve the existing `Codex` filter and add category filters: `All`, `Installed`, `Codex`, `Western`, and `Asian`. -7. Apply `Western`/`Asian` filters using validated v3 `category` metadata only. -8. Use `preview` only for selected-pet detail if available and cheap. -9. Use `spritesheet` only for selected-pet detail preview if explicitly needed; never for every card. -10. Fall back to v2 when v3 is unavailable. -11. Keep all remote image URLs main-process validated before exposing them to preload. - -Main-process data contract: - -- Add a paged v3 catalog UI state instead of requiring preload to know remote page URLs. -- Main process owns index/page fetch, validation, caching, and install lookup. -- `installPet(id)` must be able to resolve validated v3 metadata for pets outside page 0 by using a main-process cache or by fetching the needed validated page/index data. -- Installed catalog pets that are not in the first loaded page must still retain usable installed-state rows; detail/category/thumbnail can be enriched as pages load. - -Renderer behavior: - -- Do not render 1,000 cards at once; use paging or virtualization. -- Start with explicit paging/"Load more"; virtualization can be added later if needed. -- Limit concurrent remote image loads. -- Use async decoding and no referrer. -- Keep graceful empty/failure surfaces for thumbnail load errors. -- If only a thumbnail is available for a catalog pet, do not show fake animated mini state previews. Either fetch a selected-detail preview/spritesheet on selection or hide/degrade mini state previews for that pet. -- Do not change Codex/local pet rendering or import behavior in this phase. - -## Security and compatibility notes - -- Desktop CSP should continue to allow only `data:` and `https://openpets.dev` for Pet Manager images unless the implementation requires a narrower path/origin rule. -- The renderer must not independently derive `thumbnail`, `preview`, `spritesheet`, or `zip` URLs. -- Main process should validate: - - catalog index final URL, - - page URL origin/path, - - thumbnail/preview/spritesheet origin/path/extension, - - zip origin/path/extension. -- `catalog.v2.json` remains the compatibility contract for currently shipped clients. -- If the total public catalog exceeds the current v2 validator's limit, `catalog.v2.json` should remain capped to a compatible curated subset rather than silently breaking shipped clients. -- `catalog.v3` can be rolled out on the web before desktop starts consuming it. - -## Rollout plan - -1. Web-only rollout: - - Generate `thumb.webp` assets. - - Publish `catalog.v3` alongside existing v2. - - Verify URLs and asset sizes on production. -2. Desktop fallback support: - - Add v3 fetch/validation with v2 fallback. - - Keep existing Pet Manager behavior if v3 is missing. -3. Desktop performance update: - - Switch cards to `thumbnail`. - - Add paging/virtualization and bounded image loading. -4. Cleanup/observability: - - Add size checks to web sync scripts. - - Add desktop contract tests for v3 validation and fallback. - -## Acceptance criteria - -- `catalog.v2.json` output remains backward compatible. -- `catalog.v3.json` and page files are generated by all relevant web catalog sync/import flows. -- Every v3 pet has a small `thumbnail` URL under `https://openpets.dev/pets/` ending in `.webp`. -- Desktop Pet Manager card grid uses `thumbnail` for catalog pets. -- Desktop Pet Manager includes `All`, `Installed`, existing `Codex`, `Western`, and `Asian` filters when v3 metadata is available. -- `Codex` filter behavior is unchanged. -- `Western`/`Asian` filters are driven only by validated v3 `category` metadata. -- If desktop falls back to v2, `Western`/`Asian` filters are hidden or disabled because v2 does not guarantee category metadata. -- Opening Pet Manager with 1,000+ catalog pets does not request all full `spritesheet.webp` files. -- Initial Pet Manager open requests only the v3 index, first page, and thumbnails for rendered/visible cards. -- Full `spritesheet.webp` files are requested only for selected detail preview if explicitly implemented, install packages, or runtime installed pets. -- v3 outage or validation failure falls back to v2 without breaking install/default/remove operations. -- `catalog.v2.json` remains within the compatibility limit expected by shipped desktop clients. -- Codex/local pet behavior is unchanged. -- Web generation validates unique v3 IDs, category counts, page URLs, response-size budgets, and thumbnail existence. - -## Test/check plan - -Web: - -```bash -cd web -bun lint -bun run build -bun run sync:pets -``` - -Desktop: - -```bash -pnpm --filter @open-pets/desktop build -pnpm --filter @open-pets/desktop test -pnpm package:desktop:dir -``` - -Manual verification: - -1. Publish or locally serve a v3 catalog with at least 1,000 pets. -2. Open desktop Pet Manager. -3. Confirm only index/page JSON and card thumbnails are loaded initially. -4. Scroll/load more and confirm requests grow by page/viewport, not by total catalog size. -5. Switch between `All`, `Installed`, `Codex`, `Western`, and `Asian`; confirm Codex remains unchanged and category filters match web categories. -6. Select a pet and confirm detail still works. -7. Install a pet and confirm runtime installed pet behavior is unchanged. -8. Disable v3 and confirm v2 fallback works with category filters hidden/disabled. -9. Confirm Codex/local pets behave exactly as before. - -## Open questions - -- Should `preview.webp` ship in the first implementation, or should v3 start with only `thumbnail` plus existing `spritesheet`? -- Should desktop search initially search loaded pages only with explicit copy, or should the web publish a lightweight searchable index in the same phase?