Revert "Catalog v3"

This reverts commit c612b3b79c.
This commit is contained in:
Alvin Unreal 2026-05-12 19:47:45 +02:00
parent f6f076122c
commit 89aed99850
9 changed files with 28 additions and 709 deletions

View file

@ -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 || "",

View file

@ -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,
};

View file

@ -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<string>();
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<string>): 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<string>): CatalogPetV2 {
};
}
function validateCatalogPetV3(value: unknown, ids: Set<string>): 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<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -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<number, CatalogV3Page>();
let v3IndexCache: CatalogV3Index | null = null;
export async function getCatalogUiState(): Promise<CatalogUiState> {
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<CatalogUiState> {
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<CatalogUiState> {
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<CatalogUiPet> {
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<CatalogV3Index> {
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<CatalogV3Page> {
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<unknown> {
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
}
async function readLimitedResponse(response: Response, maxBytes = maxCatalogBytes): Promise<string> {
async function readLimitedResponse(response: Response): Promise<string> {
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;
}

View file

@ -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.");
}

View file

@ -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.");

View file

@ -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<OpenPetsStateV1> {
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<T>(key: string, callback: () => Promise<T
}
}
async function getCatalogPet(petId: string): Promise<CatalogPetV2 | CatalogPetV3> {
return getCatalogPetById(petId);
async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
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<Buffer> {

View file

@ -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 {
<button id="pm-filter-all" class="pm-filter active" type="button" data-pet-filter="all" aria-pressed="true">All</button>
<button id="pm-filter-installed" class="pm-filter" type="button" data-pet-filter="installed" aria-pressed="false">Installed</button>
<button id="pm-filter-codex" class="pm-filter" type="button" data-pet-filter="codex" aria-pressed="false">Codex</button>
<button id="pm-filter-western" class="pm-filter" type="button" data-pet-filter="western" aria-pressed="false" hidden>Western</button>
<button id="pm-filter-asian" class="pm-filter" type="button" data-pet-filter="asian" aria-pressed="false" hidden>Asian</button>
</div>
<span id="catalog-status" class="pm-status-pill">Loading…</span>
</div>
@ -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; } }

View file

@ -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/<slug>/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/<slug>/`, 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
<petId>.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?