Parametron

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-facet CatalogTerm returned by host.loadTerms() — toggles a global industry filter that ANDs with whatever category is already active.
  • Materials — only if host.loadMaterials is set.
  • Favorites — only if host.favorites is set.
  • My Models — only if both host.currentUserId and host.loadMyModels are 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() helper CatalogPanel uses for every label (sketchup.models, ui.favorites, ui.noModels, …).
  • Entity APIinteractable on a card mirrors whether the underlying model is a live parametric document vs. a static mesh import.
📖 4 min read