CatalogPanel is an app-store style model browser: a left icon rail plus a content column with a HOME view (search, category tiles, Featured/New Collections) and a BROWSE view (breadcrumb, grid/list toggle, card grid). It backs the model picker in both the web builder and the SketchUp plugin. Like every panel in @parametron/ui, it is host-pure — it renders entirely off an injected CatalogHost contract and touches no window/fetch/router/ storage of its own; the host owns data loading and persistence.
Import
import { CatalogPanel, modelCard, materialCard, categoryTile, CATALOG_NAVY } from "@parametron/ui"
import type { CatalogHost, CatalogTerm, CatalogMaterial, CatalogFavorites, CatalogCardModel, CatalogCardMenuItem, CatalogPanelOptions } from "@parametron/ui"@parametron/ui's main is src/index.ts (no built dist, no subpaths) — the catalog cluster re-exports through the package root, so everything above comes from the same @parametron/ui specifier.
CatalogPanel(host, opts?)
function CatalogPanel(host: CatalogHost, opts?: CatalogPanelOptions): DomphyElement<"div">interface CatalogPanelOptions {
logo?: string // logo SVG shown at the top of the rail
railBottom?: DomphyElement[] // extra rail icons pinned to the bottom (docs / account)
}Call it once per mount, like any Domphy element factory — the returned DomphyElement<"div"> is the whole panel (rail + content column), ready to drop into a layout.
Rail tabs
The rail always shows a Models tab. Everything else appears only if the host supports it:
- One rail button per
industry-facetCatalogTermreturned byhost.loadTerms()— toggles a global industry filter that ANDs with whatever category is already active. - Materials — only if
host.loadMaterialsis set. - Favorites — only if
host.favoritesis set. - My Models — only if both
host.currentUserIdandhost.loadMyModelsare set.
Home vs. browse
Without host.loadTerms, the panel degrades to a plain search + grid (browse only). With it, the Models tab starts on a HOME view — a category tile row (from category-facet terms) plus Featured (model.featured) and New Collections (sorted by createdAt) card carousels — and switches to BROWSE the moment the user types a search query, picks a category/industry, or opens Favorites/My Models/Materials. Browse mode adds a breadcrumb bar, a result count, and a grid/list layout toggle.
Every model card goes through host.thumbnailUrl (if given) to turn a raw storage key into a displayable URL, and its actions menu ("⋮") is built from whatever the host allows: a favourite toggle (host.favorites), Clone (host.onCloneModel), and Remove — the last shown only on cards where model.ownerId === host.currentUserId, and calling host.onRemoveModel then dropping the card from every list it could appear in.
CatalogHost — the contract
interface CatalogHost {
loadCatalog(opts?: { query?: string }): Promise<CatalogCardModel[]>
onSelectModel(id: string): void
thumbnailUrl?(key: string | null | undefined): string | undefined
loadMaterials?(opts?: { query?: string }): Promise<CatalogMaterial[]>
onPickMaterial?(material: CatalogMaterial): void
favorites?: CatalogFavorites
loadTerms?(): Promise<CatalogTerm[]>
greeting?: string
currentUserId?: string
loadMyModels?(): Promise<CatalogCardModel[]>
onCloneModel?(id: string): void
onRemoveModel?(id: string): Promise<void>
}Only loadCatalog and onSelectModel are required. loadCatalog is called once on mount (client-side query filtering happens in the panel too, so opts.query is an optional server-side hint); onSelectModel(id) fires on card click and is entirely the host's call — Studio.placeBlock, a route push, host.placeBlock on the SketchUp bridge, whatever "select" means for that host.
interface CatalogFavorites {
isFavorite(id: string, listener?: Listener): boolean
toggle(id: string): void
}Pass the render Listener through isFavorite so the grid reactively re-sorts (favourites first) and re-stars when the set changes. Omit favorites entirely and the panel shows no stars, no Favorites tab, and no favourite-first sort.
interface CatalogTerm {
id: string
slug: string
label: string
facet: "category" | "industry" | "brand"
icon?: string | null
}One shape backs three independent, many-valued facets — a model can carry several term ids per facet (CatalogCardModel.categoryIds / industryIds / brandIds), so there is no parentId nesting. For a category tile's icon: if host.thumbnailUrl is set, it resolves any icon string to a background-image URL; otherwise only a string that already looks like a URL (http(s):, data:, or a leading /) is used directly. Anything else falls back to a generic cube glyph — there is no lookup of a bare name into a shared icon set.
interface CatalogMaterial {
id: string
name: string
color?: string | null
}The catalog's optional Materials tab is a read-only swatch browser for picking a paint material (host.onPickMaterial) — distinct from Material panels's MaterialsPanel/MaterialEditor, which author a document's own materials[] lane.
Model cards
type CatalogCardModel = {
id: string
title: string
thumbnail?: string | null
isPro?: boolean
subtitle?: string | null // small line under the title
interactable?: boolean // shows the "↗" parametric badge
categoryIds?: string[]
industryIds?: string[]
brandIds?: string[]
tags?: string[]
createdAt?: string
featured?: boolean
ownerId?: string
}
type CatalogCardMenuItem = { label: string; onClick: () => void; danger?: boolean }
function modelCard(
m: CatalogCardModel,
opts: { onClick: () => void; menuItems?: CatalogCardMenuItem[] },
): DomphyElement<"div">modelCard is the shared card renderer (thumbnail, title, Free/Pro badge, optional "⋮" actions menu) used by both the web workspace and the SketchUp plugin — it never decides what actions exist; the caller (CatalogPanel, or your own list view) builds menuItems from its own knowledge of favourites/ ownership/host capabilities.
type CatalogCardMaterial = { name: string; color?: string | null }
function materialCard(m: CatalogCardMaterial, opts?: { onClick?: () => void }): DomphyElement<"div">
function categoryTile(
label: string,
opts?: { active?: boolean; onClick?: () => void; iconUrl?: string | null },
): DomphyElement<"button">materialCard renders a swatch + name (used for both the read-only web gallery and a click-to-paint SketchUp picker); categoryTile is the square icon+label tile used in the home view's category row. Both, plus modelCard, are exported so a host can reuse the same visual language outside CatalogPanel — e.g. a search-results page that doesn't need the rail.
CATALOG_NAVY ("#091b61") is the fixed brand-navy accent shared by the rail, active-state highlights, and the Pro badge — carried over from the legacy 3dshouse app, deliberately outside Domphy's theme-tone system (see the doc comments on railButton/rail in CatalogPanel.ts for why).
Minimal wiring
import { CatalogPanel } from "@parametron/ui"
import type { CatalogHost } from "@parametron/ui"
const host: CatalogHost = {
async loadCatalog() {
const res = await fetch("/api/models")
return res.json()
},
onSelectModel(id) {
location.href = `/model/${id}`
},
}
const panel = CatalogPanel(host, { logo: "<svg>...</svg>" })This is the browse-only degraded mode (no loadTerms): search box + result grid, no home view, no industry/materials/favorites/My-Models tabs. Add loadTerms, favorites, loadMaterials, or currentUserId+loadMyModels incrementally — each one unlocks exactly the rail tab / view it's paired with above.
See also
- UI overview & chrome — where panels like this one mount inside the reference rail/router shell.
- Material panels — authoring a model's own materials, as opposed to this panel's read-only material picker.
- i18n catalog — the
t()helperCatalogPaneluses for every label (sketchup.models,ui.favorites,ui.noModels, …). - Entity API —
interactableon a card mirrors whether the underlying model is a live parametric document vs. a static mesh import.