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?”
@nodaro/shared(library) → pure/static/deterministic: catalogs, prompt assembly, types. Nothing to round-trip a server for.@nodaro/sdkSDK +/v1API → server state: jobs, credits, uploads, workflows, live model pricing.
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)
- Catalogs with
promptHinton every entry — ~25+ single-dim catalogs inpackages/shared/src/*.ts(mood, lens, setting, pose, lighting, color-look, atmosphere, style, era, photo-genre, backdrop, aesthetic, render-quality, transitions, character-fx, materials, animals, vehicles, weapons, furniture, held-prop, action-fx, loop-subject, …), plus multi-dim (person, styling, framing, temporal, exposure-settings, music-genre). - Prompt assembly —
getParameterPromptHint(node, ctx?)(single source of truth, used by both frontend executor and backend) + per-catalogget*PromptHint(id)/build*Hints(value). - Value resolution —
getParameterValue+PARAMETER_NODE_TYPES(parameter-node-value.ts). - i18n — already complete in
shared.packages/shared/src/i18n/carries per-catalog, per-locale bundles across 12 locales (en, es, fr, de, pt-BR, ru, hi, ja, ko, zh-CN, he, ar);I18nCatalogIdis the typed key. Localized labels resolve via the catalog’scatalogId. Nothing to add here — the contract just needs to exposecatalogIdso consumers can localize.
What’s missing (this work adds)
- 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_CATALOGSregistry. - 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 optionalicon?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:
- Data-native visuals (lift now): emoji (
MoodEmoji, animal/vehicle/weapon icons) and swatches/colors → expose viaicon?. Cheap, high-value. - Component-rendered visuals (NOT data):
SettingPreview,ColorLookPreview,AtmospherePreview,LensPreview, etc. render generated SVG/graphics. There is nothing to “lift.” Consumers render their own visual fromlabel/category— which is exactly what studio wants (it rebuilds its own pickers to match its own design language). - Rich-preview parity for third parties (future, only on demand): render each preview once → static SVG/PNG on R2 → add
thumbnailUrlto the catalog data. This turns component-visuals into pure data for everyone, with no React shipped. This is the right path instead of shipping styled components (drags the design system + contaminates the backend, which also importsshared) or a headless kit (gives behavior, not the pretty tile).
Non-goals (v1)
- No
/v1/catalogsAPI (add only if non-JS / runtime / parity demand appears). - No styled component export; no headless React kit.
- No full visual parity (thumbnail-baking deferred).
Phasing
- v1 (this change):
PICKER_CATALOGSregistry +icon?field (data-native populated) + drift test + i18n exposure. Ship todev, deploy tomain. - Phase 2: frontend
SINGLE_PICKERS/MULTI_PICKERSderive fromPICKER_CATALOGS(collapse the duplication the drift test currently guards); add@nodaro/shared/catalogssubpath export for a clean public boundary; register the@nodaronpm scope + publish; studio un-vendors (npm installinstead offile:). - Phase 3 (on demand): thumbnail-baking for rich visual parity; optional headless kit; optional read-only
/v1/catalogsprojection for non-JS consumers.
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(", ") })