Picker Catalogs as a Public Contract — Design

Date: 2026-06-05 Status: Implementing (branch feat/picker-catalogs-public-contract)

Goal

Let studio and other external apps consume the parameter-picker catalogs (options, prompt fragments, categories, i18n) as a stable contract, without coupling to app-internal code — by shipping them as data in @nodaro/shared, not behind API/SDK endpoints.

Decision: library, not API

The test is “server state, or static config?”

Pickers are static config + pure functions → they belong in the library. An API for catalogs only earns its place if one of these appears (none apply today): (1) non-JS consumers (npm install impossible), (2) server-driven catalogs (plan/edition gating, A/B, personalization), (3) guaranteed live-parity without consumers upgrading. If any shows up, add a read-only GET /v1/catalogs that is a thin projection of the same package — one source of truth, two surfaces.

What a picker is

A catalog entry { id, label, category?, description?, promptHint } + a prompt-fragment function (get*PromptHint / the unified getParameterPromptHint) + an app-only React preview. The first two are pure data/logic; only the preview is app UI.

What already exists (verified)

What’s missing (this work adds)

  1. No discoverable aggregate. The picker catalogs are ~30 separate exports with no single, typed “these are the pickers” surface. A consumer has to know every export name. → Add a PICKER_CATALOGS registry.
  2. Visual metadata lives as React, not data. The picker visuals are bespoke per-catalog React preview components in the frontend registry (SettingPreview, MoodEmoji, ColorLookPreview, PoseIcon, …) — not catalog fields. → Add an optional icon? field to the contract; populate the data-native ones; document the rest.

The contract (added to @nodaro/shared)

interface PickerOption {
  id: string
  label: string                 // English canonical; localize via catalogId + i18n
  description?: string
  category?: string             // group id (matches categoryOrder/Labels)
  promptHint: string            // the clause this option contributes
  icon?: string                 // data-native visual only (emoji / swatch); see below
}

interface PickerCatalog {
  nodeType: string              // e.g. "mood"
  label: string                 // e.g. "Mood"
  catalogId: I18nCatalogId      // i18n key for localized labels
  kind: "single" | "multi"
  valueField?: string           // node-data field a single picker writes
  defaultValue?: string
  categoryOrder?: readonly string[]
  categoryLabels?: Readonly<Record<string, string>>
  options?: readonly PickerOption[]   // single-dim
  fields?: readonly string[]          // multi-dim dimension keys
  dimensions?: readonly { field: string; label: string; options: readonly PickerOption[] }[] // multi-dim, self-describing
}

export const PICKER_CATALOGS: readonly PickerCatalog[]
export function getPickerCatalog(nodeTypeOrCatalogId: string): PickerCatalog | undefined
export function listPickerCatalogs(): readonly PickerCatalog[]

Exported from the package root (@nodaro/shared). Options reference the existing catalog arrays (no data duplication).

Drift guard (invariant + test, not “remember to update”): a frontend test asserts PICKER_CATALOGS stays in lock-step with the frontend picker registry (SINGLE_PICKERS / MULTI_PICKERS) — same nodeTypes, catalogIds, valueFields, defaults, category order/labels, and option ids. If the app adds/edits a picker and forgets the registry, the test fails.

Visual metadata — the honest split

Investigation showed the visuals are not simple liftable data; they’re custom React components. So:

Non-goals (v1)

Phasing

Consumer usage (studio)

import { PICKER_CATALOGS, getParameterPromptHint } from "@nodaro/shared"
import { client } from "@/lib/nodaro"

const mood = PICKER_CATALOGS.find((c) => c.nodeType === "mood")!
// render your own tile-grid from mood.options (+ mood.categoryOrder/Labels, localized via mood.catalogId)
const clause = getParameterPromptHint({ type: "mood", mood: selectedId }) // or selectedOption.promptHint
const { jobIds } = await client.nodes.run("generate-image", { prompt: [base, clause].filter(Boolean).join(", ") })