diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs index 9406d166..588669f9 100644 --- a/apps/desktop/preload.cjs +++ b/apps/desktop/preload.cjs @@ -38,6 +38,8 @@ let activePetManagerSelection = ""; let activePetManagerFilter = "all"; let activePetManagerItems = []; let activePetManagerDefaultId = ""; +let petGalleryInstance = 0; +const remoteCatalogFilters = new Set(["original", "western", "asian"]); contextBridge.exposeInMainWorld("openPets", api); contextBridge.exposeInMainWorld("openpetsAgentSetup", agentSetupApi); @@ -649,6 +651,7 @@ async function renderPetManager(state) { } function renderPetGallery(catalogState, codexState, state, defaultPetId) { + const instance = ++petGalleryInstance; const status = requireElement("catalog-status"); const search = requireInput("catalog-search"); const grid = requireElement("catalog-pets"); @@ -671,8 +674,7 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { const catalogPageCount = catalogState.pageCount || 1; let catalogSearchState = null; let remoteResultLimit = 100; - let activeRemoteResultIds = null; - let activeRemoteHasMore = false; + let renderGeneration = 0; let pets = createPetManagerItems({ ...catalogState, pets: catalogPets }, codexState, state, defaultPetId, defaultThumbnailSrc); activePetManagerItems = pets; activePetManagerDefaultId = defaultPetId; @@ -680,18 +682,33 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { activePetManagerSelection = defaultPetId || pets[0]?.id || ""; } + const resetPetGalleryViewport = () => { + grid.scrollTop = 0; + document.querySelector(".pm-gallery-pane")?.scrollTo?.({ top: 0, behavior: "instant" }); + }; + + const isSupportedFilter = (filterName) => { + if ((filterName === "western" || filterName === "asian") && !catalogState.supportsCategories) return false; + if (filterName === "original" && typeof catalogState.originalsCount !== "number") return false; + return true; + }; + for (const filter of document.querySelectorAll("[data-pet-filter]")) { - if ((filter.dataset.petFilter === "western" || filter.dataset.petFilter === "asian") && !catalogState.supportsCategories) { + const filterName = filter.dataset.petFilter || "all"; + if (!isSupportedFilter(filterName)) { filter.hidden = true; - if (activePetManagerFilter === filter.dataset.petFilter) activePetManagerFilter = "all"; + if (activePetManagerFilter === filterName) activePetManagerFilter = "all"; } else { filter.hidden = false; } filter.classList.toggle("active", filter.dataset.petFilter === activePetManagerFilter); filter.setAttribute("aria-pressed", filter.dataset.petFilter === activePetManagerFilter ? "true" : "false"); filter.onclick = () => { - activePetManagerFilter = filter.dataset.petFilter || "all"; + const nextFilter = filter.dataset.petFilter || "all"; + if (nextFilter === activePetManagerFilter) return; + activePetManagerFilter = nextFilter; remoteResultLimit = 100; + resetPetGalleryViewport(); void render(); }; } @@ -717,46 +734,56 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { return false; }; - const shouldUseRemoteResults = (query) => catalogState.version === 3 && (Boolean(query) || activePetManagerFilter === "western" || activePetManagerFilter === "asian"); + const shouldUseRemoteResults = (filterName, query) => catalogState.version === 3 && ((filterName === "all" && Boolean(query)) || remoteCatalogFilters.has(filterName)); - const ensureRemoteResultsLoaded = async (query) => { - activeRemoteResultIds = null; - activeRemoteHasMore = false; - if (!shouldUseRemoteResults(query)) return; + const getRemoteResults = async (filterName, query) => { + if (!shouldUseRemoteResults(filterName, query)) return { ids: null, hasMore: false }; catalogSearchState ||= await api.getCatalogSearch(); if (!isCatalogSearchUiState(catalogSearchState) || catalogSearchState.source === "error") throw new Error(catalogSearchState?.error || "Catalog search unavailable."); const matches = catalogSearchState.pets.filter((pet) => { - if (activePetManagerFilter !== "all" && activePetManagerFilter !== "installed" && activePetManagerFilter !== "codex" && pet.category !== activePetManagerFilter) return false; + if (filterName === "western" || filterName === "asian") { + if (pet.category !== filterName) return false; + } else if (filterName === "original" && !pet.original) { + return false; + } return !query || pet.searchText.includes(query); }); const visibleMatches = matches.slice(0, remoteResultLimit); - activeRemoteResultIds = new Set(visibleMatches.map((pet) => pet.id)); - activeRemoteHasMore = matches.length > visibleMatches.length; const pages = new Set(visibleMatches.map((pet) => pet.catalogPage)); await Promise.all([...pages].map((page) => loadCatalogPage(page))); + return { ids: new Set(visibleMatches.map((pet) => pet.id)), hasMore: matches.length > visibleMatches.length }; }; const render = async () => { + const generation = ++renderGeneration; + const filterName = activePetManagerFilter; + const query = search.value.trim().toLowerCase(); + const isStale = () => instance !== petGalleryInstance || generation !== renderGeneration || filterName !== activePetManagerFilter || query !== search.value.trim().toLowerCase(); for (const filter of document.querySelectorAll("[data-pet-filter]")) { - filter.classList.toggle("active", filter.dataset.petFilter === activePetManagerFilter); - filter.setAttribute("aria-pressed", filter.dataset.petFilter === activePetManagerFilter ? "true" : "false"); + filter.classList.toggle("active", filter.dataset.petFilter === filterName); + filter.setAttribute("aria-pressed", filter.dataset.petFilter === filterName ? "true" : "false"); } - const query = search.value.trim().toLowerCase(); - if (shouldUseRemoteResults(query)) { - try { - await ensureRemoteResultsLoaded(query); - } catch (error) { - renderCaughtError(error); - } + let remoteResults = { ids: null, hasMore: false }; + let remoteError = null; + try { + remoteResults = await getRemoteResults(filterName, query); + } catch (error) { + remoteError = error; } + if (isStale()) { + return; + } + if (remoteError) renderCaughtError(remoteError); + 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; + if (filterName === "installed" && !pet.installed) return false; + if (filterName === "codex" && !pet.codexPet && !pet.codexImported) return false; + if (filterName === "original" && !pet.original) return false; + if ((filterName === "western" || filterName === "asian") && pet.category !== filterName) return false; const haystack = `${pet.id} ${pet.displayName} ${pet.description}`.toLowerCase(); - if (activeRemoteResultIds) { - const remoteMatch = Boolean(pet.catalogPet && activeRemoteResultIds.has(pet.id)); + if (remoteResults.ids) { + const remoteMatch = Boolean(pet.catalogPet && remoteResults.ids.has(pet.id)); return query ? remoteMatch || haystack.includes(query) : remoteMatch; } return haystack.includes(query); @@ -771,12 +798,12 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { if (visiblePets.length === 0) { const empty = document.createElement("div"); empty.className = "pm-empty-state"; - empty.textContent = activePetManagerFilter === "installed" ? "No installed pets match your search." : activePetManagerFilter === "codex" ? "No Codex pets match your search." : activePetManagerFilter === "western" || activePetManagerFilter === "asian" ? `No ${activePetManagerFilter} pets match your search.` : "No pets match your search."; + empty.textContent = createEmptyPetGalleryMessage(filterName); grid.append(empty); } - const hasMoreCatalogPages = catalogState.version === 3 && !query && !shouldUseRemoteResults(query) && loadedCatalogPages.size < catalogPageCount; - const hasMoreRemoteResults = activeRemoteHasMore; + const hasMoreCatalogPages = filterName === "all" && catalogState.version === 3 && !query && loadedCatalogPages.size < catalogPageCount; + const hasMoreRemoteResults = remoteResults.hasMore; if (hasMoreCatalogPages || hasMoreRemoteResults) { const loadMore = document.createElement("button"); loadMore.className = "pm-load-more"; @@ -812,6 +839,7 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) { search.oninput = () => { remoteResultLimit = 100; + resetPetGalleryViewport(); void render(); }; void render(); @@ -856,6 +884,8 @@ function createPetManagerItem(id, displayName, description, installed, catalogPe displayName, description, category: catalogPet?.category || "", + original: Boolean(catalogPet?.original), + featured: Boolean(catalogPet?.featured), installed, catalogPet, codexPet, @@ -871,6 +901,15 @@ function createPetManagerItem(id, displayName, description, installed, catalogPe }; } +function createEmptyPetGalleryMessage(filterName) { + if (filterName === "installed") return "No installed pets match your search."; + if (filterName === "codex") return "No Codex pets match your search."; + if (filterName === "original") return "No OpenPets originals match your search."; + if (filterName === "western") return "No Western pets match your search."; + if (filterName === "asian") return "No Asian pets match your search."; + return "No pets match your search."; +} + function createPetGalleryCard(pet, defaultPetId, onSelect) { const card = document.createElement("article"); card.className = pet.id === activePetManagerSelection ? "pm-pet-card active" : "pm-pet-card"; diff --git a/apps/desktop/src/catalog-validation.ts b/apps/desktop/src/catalog-validation.ts index 200fdb27..08ae65dd 100644 --- a/apps/desktop/src/catalog-validation.ts +++ b/apps/desktop/src/catalog-validation.ts @@ -13,6 +13,8 @@ export interface CatalogPetV2 { readonly spritesheet?: string; readonly category?: "western" | "asian"; readonly subcategory?: string; + readonly original?: boolean; + readonly featured?: boolean; } export interface CatalogV3Index { @@ -23,6 +25,8 @@ export interface CatalogV3Index { readonly search: string; readonly filters: { readonly categories: readonly CatalogV3Category[]; + readonly originalsCount?: number; + readonly featuredCount?: number; }; readonly pages: readonly string[]; } @@ -61,6 +65,8 @@ export interface CatalogV3SearchPet { readonly searchText: string; readonly category: "western" | "asian"; readonly catalogPage: number; + readonly original?: boolean; + readonly featured?: boolean; } export interface CatalogPetV3 { @@ -72,6 +78,8 @@ export interface CatalogPetV3 { readonly zip: string; readonly category: "western" | "asian"; readonly subcategory?: string; + readonly original?: boolean; + readonly featured?: boolean; } export function validateCatalogV2(value: unknown): CatalogV2 { @@ -111,7 +119,11 @@ export function validateCatalogV3Index(value: unknown): CatalogV3Index { total, pageSize, search, - filters: { categories }, + filters: { + categories, + ...(value.filters.originalsCount === undefined ? {} : { originalsCount: validateCount(value.filters.originalsCount) }), + ...(value.filters.featuredCount === undefined ? {} : { featuredCount: validateCount(value.filters.featuredCount) }), + }, pages, }; } @@ -198,8 +210,8 @@ function validateCatalogV3Pet(value: unknown, ids: Set): CatalogPetV3 { zip: validateCatalogUrl(value.zip, "zip"), category, }; - if (value.subcategory !== undefined) return { ...entry, subcategory: validateString(value.subcategory, "subcategory", 80) }; - return entry; + const withSubcategory = value.subcategory === undefined ? entry : { ...entry, subcategory: validateString(value.subcategory, "subcategory", 80) }; + return withCatalogMeta(withSubcategory, value); } function validateCatalogV3Category(value: unknown): CatalogV3Category { @@ -214,15 +226,28 @@ function validateCatalogV3Category(value: unknown): CatalogV3Category { function validateCatalogV3SearchPet(value: unknown, catalogPageCount: number): CatalogV3SearchPet { if (!isRecord(value)) throw new Error("Catalog v3 search pet must be an object."); const catalogPage = validateInteger(value.catalogPage, "Catalog v3 search catalogPage", 0, Math.max(0, catalogPageCount - 1)); - return { + return withCatalogMeta({ id: validateId(value.id), displayName: validateString(value.displayName, "displayName", 120), searchText: validateString(value.searchText, "searchText", 400), category: validateCategory(value.category), catalogPage, + }, value); +} + +function withCatalogMeta(entry: T, value: Record): T & { readonly original?: boolean; readonly featured?: boolean } { + return { + ...entry, + ...(value.original === undefined ? {} : { original: validateBoolean(value.original, "original") }), + ...(value.featured === undefined ? {} : { featured: validateBoolean(value.featured, "featured") }), }; } +function validateBoolean(value: unknown, field: string): boolean { + if (typeof value !== "boolean") throw new Error(`Catalog pet ${field} must be a boolean.`); + return value; +} + function validateCategory(value: unknown): "western" | "asian" { if (value === "western" || value === "asian") return value; throw new Error(`Invalid catalog category: ${String(value)}`); diff --git a/apps/desktop/src/catalog.ts b/apps/desktop/src/catalog.ts index d5b73df4..f39a8596 100644 --- a/apps/desktop/src/catalog.ts +++ b/apps/desktop/src/catalog.ts @@ -23,6 +23,8 @@ export interface CatalogUiState { readonly page?: number; readonly pageCount?: number; readonly supportsCategories?: boolean; + readonly originalsCount?: number; + readonly featuredCount?: number; } export interface CatalogSearchUiState { @@ -53,6 +55,8 @@ export async function getCatalogUiState(): Promise { page: 0, pageCount: remoteV3.index.pages.length, supportsCategories: true, + originalsCount: remoteV3.index.filters.originalsCount, + featuredCount: remoteV3.index.filters.featuredCount, }; } @@ -77,6 +81,8 @@ export async function getCatalogPageUiState(page: number): Promise { diff --git a/apps/desktop/src/windows.ts b/apps/desktop/src/windows.ts index 4f7e70ea..0ad15846 100644 --- a/apps/desktop/src/windows.ts +++ b/apps/desktop/src/windows.ts @@ -380,20 +380,23 @@ function createPetManagerHtml(definition: TaskWindowDefinition): string {

Install a Pet

Pick a companion for your terminal.

+ Loading…
-
+
- -
- Loading… +
+ + + +
@@ -982,10 +985,10 @@ function createTaskWindowStyles(): string { body[data-openpets-view="pet-manager"] .pm-shell { width: min(1160px, calc(100vw - 36px)); height: calc(100vh - 28px); display: grid; grid-template-columns: minmax(330px, 0.78fr) minmax(510px, 1.22fr); gap: 32px; align-items: stretch; padding: 10px 0 18px; overflow: hidden; } body[data-openpets-view="pet-manager"] .pm-gallery-pane { position: relative; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; padding-bottom: 28px; } body[data-openpets-view="pet-manager"] .pm-logo { width: min(218px, 72%); flex: 0 0 auto; display: block; margin: -2px auto 0; filter: drop-shadow(0 10px 14px rgba(42, 80, 138, 0.12)); } - body[data-openpets-view="pet-manager"] .pm-header { flex: 0 0 auto; display: grid; align-items: center; row-gap: 0; margin-bottom: 6px; } + body[data-openpets-view="pet-manager"] .pm-header { flex: 0 0 auto; position: relative; display: grid; align-items: center; row-gap: 0; margin-bottom: 6px; padding-right: 108px; } body[data-openpets-view="pet-manager"] .pm-header h1 { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 24px; line-height: 1.08; letter-spacing: -0.03em; color: #102149; text-shadow: 0 1px 0 rgba(255,255,255,0.9); } body[data-openpets-view="pet-manager"] .pm-header .lede { grid-column: 1 / -1; margin: 0; color: #63708f; line-height: 1.25; } - body[data-openpets-view="pet-manager"] .pm-status-pill { position: absolute; right: 8px; bottom: 0; height: 22px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; border: 1px solid rgba(126, 161, 210, 0.28); border-radius: 8px; padding: 0 8px; color: #526483; background: rgba(255, 255, 255, 0.58); font-size: 9px; line-height: 1; font-weight: 850; font-variant-numeric: tabular-nums; white-space: nowrap; box-shadow: 0 8px 18px rgba(61, 99, 160, 0.05), inset 0 1px 0 rgba(255,255,255,0.85); } + body[data-openpets-view="pet-manager"] .pm-status-pill { position: absolute; right: 0; top: 2px; height: 22px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; border: 1px solid rgba(126, 161, 210, 0.28); border-radius: 8px; padding: 0 8px; color: #526483; background: rgba(255, 255, 255, 0.58); font-size: 9px; line-height: 1; font-weight: 850; font-variant-numeric: tabular-nums; white-space: nowrap; box-shadow: 0 8px 18px rgba(61, 99, 160, 0.05), inset 0 1px 0 rgba(255,255,255,0.85); } body[data-openpets-view="pet-manager"] .pm-status-pill.success { color: #047857; background: rgba(236, 253, 245, 0.82); border-color: rgba(16, 185, 129, 0.26); } body[data-openpets-view="pet-manager"] .pm-status-pill.error { color: #b91c1c; background: rgba(254, 242, 242, 0.86); border-color: rgba(248, 113, 113, 0.28); } body[data-openpets-view="pet-manager"] .pm-search-wrap { height: 40px; flex: 0 0 auto; display: flex; align-items: center; gap: 11px; box-sizing: border-box; margin: 0 0 8px; padding: 0 13px; border: 1px solid rgba(126, 161, 210, 0.54); border-radius: 12px; background: rgba(255,255,255,0.82); box-shadow: inset 0 1px 0 rgba(255,255,255,0.92), 0 10px 24px rgba(61, 99, 160, 0.08); color: #526483; } @@ -994,10 +997,14 @@ function createTaskWindowStyles(): string { body[data-openpets-view="pet-manager"] .pm-search-icon::after { content: ""; width: 7px; height: 2px; border-radius: 999px; background: #5d6e8e; position: absolute; right: -5px; bottom: -2px; transform: rotate(45deg); transform-origin: center; } body[data-openpets-view="pet-manager"] #catalog-search { width: 100%; border: 0; outline: 0; background: transparent; color: #17284f; font: inherit; font-size: 15px; } body[data-openpets-view="pet-manager"] #catalog-search::placeholder { color: #8290aa; } - body[data-openpets-view="pet-manager"] .pm-filters { flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-start; gap: 10px; margin-bottom: 9px; } - body[data-openpets-view="pet-manager"] .pm-filter-buttons { display: flex; align-items: center; justify-content: flex-start; gap: 9px; min-width: 0; } - body[data-openpets-view="pet-manager"] .pm-filter { min-width: 64px; min-height: 30px; padding: 5px 12px; border: 1px solid rgba(126, 161, 210, 0.46); border-radius: 10px; background: rgba(255,255,255,0.76); color: #526483; font-weight: 850; box-shadow: 0 8px 20px rgba(61, 99, 160, 0.06); } + body[data-openpets-view="pet-manager"] .pm-filters { flex: 0 0 auto; position: relative; display: grid; gap: 7px; margin-bottom: 9px; } + body[data-openpets-view="pet-manager"] .pm-filter-buttons { display: grid; align-items: center; justify-content: stretch; gap: 8px; min-width: 0; } + body[data-openpets-view="pet-manager"] .pm-filter-buttons.primary { grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: 315px; } + body[data-openpets-view="pet-manager"] .pm-filter-buttons.secondary { grid-template-columns: repeat(3, minmax(0, 1fr)); } + body[data-openpets-view="pet-manager"] .pm-filter { min-width: 0; min-height: 30px; padding: 5px 12px; border: 1px solid rgba(126, 161, 210, 0.46); border-radius: 10px; background: rgba(255,255,255,0.76); color: #526483; font-weight: 850; box-shadow: 0 8px 20px rgba(61, 99, 160, 0.06); display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; } + body[data-openpets-view="pet-manager"] .pm-filter[data-pet-filter="original"] { border-color: rgba(217, 119, 6, 0.36); background: linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(254, 243, 199, 0.82)); color: #9a5a08; box-shadow: 0 9px 20px rgba(217, 119, 6, 0.09), inset 0 1px 0 rgba(255,255,255,0.9); } body[data-openpets-view="pet-manager"] .pm-filter.active { border-color: rgba(29, 113, 255, 0.5); background: linear-gradient(180deg, #53a3ff, #176df2); color: #fff; box-shadow: 0 10px 22px rgba(37, 99, 235, 0.25), inset 0 1px 0 rgba(255,255,255,0.34); } + body[data-openpets-view="pet-manager"] .pm-filter[data-pet-filter="original"].active { border-color: rgba(180, 83, 9, 0.58); background: linear-gradient(180deg, #fbbf24, #d97706); color: #fff7ed; box-shadow: 0 12px 24px rgba(217, 119, 6, 0.24), inset 0 1px 0 rgba(255,255,255,0.34); } body[data-openpets-view="pet-manager"] .pm-pet-grid { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 2px 8px 14px 2px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; scrollbar-color: rgba(96, 165, 250, 0.42) transparent; } body[data-openpets-view="pet-manager"] .pm-pet-card { min-height: 164px; display: grid; grid-template-rows: 58px 40px 31px; gap: 7px; justify-items: center; box-sizing: border-box; padding: 11px 10px 12px; border: 1px solid rgba(126, 161, 210, 0.45); border-radius: 14px; background: rgba(255,255,255,0.74); color: #14264d; box-shadow: 0 12px 28px rgba(61, 99, 160, 0.1), inset 0 1px 0 rgba(255,255,255,0.92); transition: box-shadow 140ms ease, border-color 140ms ease, background-color 140ms ease; } body[data-openpets-view="pet-manager"] .pm-pet-card:hover { border-color: rgba(37, 99, 235, 0.44); background: rgba(255,255,255,0.9); box-shadow: 0 13px 28px rgba(61, 99, 160, 0.13), inset 0 1px 0 rgba(255,255,255,0.94); }