Skip to content

Generation Families — Frontend (Descriptor Model)

Generation Families — Frontend (Descriptor Model)

How the faceted generation UI builds itself. For the concept (families, axes, tuples, resolution) see Generation Families; this doc is the frontend engineering view.

The thesis

The frontend renders no hand-coded form per generation product. The server resolves a workflow family into one contract; the frontend turns that contract (plus the family’s axes) into a flat list of typed descriptors; a small registry renders each descriptor into a component and wires it to form state. Pages choose where controls go; the registry decides how each one looks. Adding a control type, a new field, or a whole new product needs little-to-no bespoke UI code.

flowchart LR
A["sirloin / tRPC<br/>catalog item:<br/>axes + variants +<br/>union contract"] --> B["useWorkflowForm(item)<br/>builds descriptors,<br/>owns form state"]
B --> C["ControlDescriptor[]<br/>(axis + field, typed by kind)"]
C --> D["WorkflowControls<br/>(registry container)"]
D --> E["Control<br/>switch(kind) -> component<br/>+ form bucket"]
B --> F["buildPayload()<br/>axes -> generationAxes,<br/>inputs pruned to variant"]
E --> G["Host composes controls<br/>+ GeneratePageLayout"]

1. Centralised resolution → one contract

A catalog item (CatalogItem, from generationCatalog.list) carries axes, variants, and a single contract. For a faceted family the server synthesises that contract as the deduped union of every variant’s contract (unionVariantContract in server/api/generation-catalog/contract.ts); a singleton workflow carries its own contract. Either way the frontend receives one uniform shape.

The consequence that shapes everything downstream: the rendered field set is static. useWorkflowForm reads item.contract.inputs once — axis picks choose which workflow resolves at submit, never which fields show. The form mirrors the server’s resolution (resolveVariant) only to drive the price/availability preview; the authoritative match happens server-side at dispatch. At submit, buildPayload routes axis values into generationAxes (not inputs) and prunes the merged input buckets down to the resolved variant’s own inputs, so a value typed for one variant is never sent to a workflow that doesn’t accept it.

2. Descriptors — the typed middle layer

A descriptor is a pure, declarative description of one control. ControlDescriptor (in descriptors.ts) is a discriminated union on kind:

kindsourcerenders asform bucket
axis-togglefamily axis (toggle)SwitchselectedAxes
axis-selectfamily axis (dropdown)SelectselectedAxes
field-selectaspect-ratio / duration / enum inputSelectscalarInputs
field-numbernumeric scalar inputnumber InputscalarInputs
field-texttext inputTextareatextInputs
field-charactercharacter-ids inputCharacterMultiSelectInputcharacterInputs
field-mediamedia inputFileInputfileInputs

Two pure mappers build them:

  • axisDescriptor(axis) — a family axis → axis-toggle or axis-select (by the axis’s control kind).
  • fieldDescriptor(input) — a contract input → its control kind. The decision order: aspect-ratio → select (enum or DEFAULT_ASPECT_RATIOS); duration → numeric select (enum or integerRange(min,max)); any other enum → select; numeric scalar → number (with min/max/step from the JSON type); TEXT → text; character-ids → character; otherwise → media.

useWorkflowForm exposes them pre-splitaxisDescriptors, fieldDescriptors, and the combined descriptors — which is the seam for placement (see §4).

3. The registry — rendering + appearance

<WorkflowControls descriptors={…} form={…}> is the registry container: it maps a slice of descriptors to <Control> elements. Hand it form.axisDescriptors, form.fieldDescriptors, or the whole form.descriptors stream.

<Control> is the dispatcher: a single switch (descriptor.kind) that picks the component and wires it to the matching bucket + handler on the form hook (onAxisChange, onScalarChange, onTextChange, onCharacterChange, onFilesChange). Each case also owns its markup — e.g. axis-toggle is a bordered, rounded row with a label and a Switch — so “how a control looks” lives here, per kind. The text/number/select field controls share <FieldShell> (label + required marker + optional description) for consistent chrome; axis and character/media controls render their own layout.

This is the whole rendering surface: one switch. There is no per-product form component.

4. Placement — the host decides where

Because descriptors are exposed pre-split, a page composes panels however it likes:

const form = useWorkflowForm(item);
const controls = (
<>
{/* inputs in one panel… */}
<WorkflowControls descriptors={form.fieldDescriptors} form={form} characters={characters} />
{/* …axes in another */}
<WorkflowControls descriptors={form.axisDescriptors} form={form} />
{/* …plus host-owned, gated controls */}
<QuantityPicker />
<GenerateButton onClick={() => generate(form.buildPayload())} />
</>
);
<GeneratePageLayout preview={preview} controls={controls} mediaType={mediaType} />;

GeneratePageLayout (features/create-generate/components/generate-page-layout) is the shared shell: it takes preview and controls as ReactNode slots, adds the back button and the Similar-Ideas / Recent-Generations rails, and lays out preview-beside-controls. The host owns everything product-specific — character selection, credits, NSFW entitlement gating, and the generate mutation itself — while the hook owns field state and produces the server-ready payload via buildPayload().

5. Where the flexibility comes from

  • New control type → add a kind to ControlDescriptor, a rule in fieldDescriptor/axisDescriptor, and a case in Control. Nothing else changes.
  • New field on a workflow → nothing: it appears in the contract, becomes a descriptor, and renders automatically.
  • New product / family → no bespoke form. The same hook + registry drive it.
  • Placement is host-controlled (pre-split descriptor streams → as many panels as you want).
  • Appearance is registry-controlled (per-kind markup + shared FieldShell).
  • Host-agnostic → the same engine drives the production photo create page, the gallery reuse dialog, and the flank dev page.

6. Seeded hosts (the reuse dialog)

A host can open the form pre-filled by passing a WorkflowSeed ({ axes, workflowName? }, in generation-catalog/constants.ts) to useWorkflowForm — applied in the same render-time reseed that sets the initial tuple. Gallery reuse uses workflowName to restore the exact available family variant; its complete tuple takes precedence over the resolver’s ordered pick when explicitness matches. Invalid or stale provenance is ignored and the ordered resolver remains the fallback. Three guarantees make seeding safe:

  • Axis filtering — seeded keys that aren’t real axes of the item are dropped (a stray key would break variant resolution and leak into generationAxes).
  • The ordered resolver (resolveOrderedTuple in axes.ts) — variants are exact tuples, so every tuple pick (initial seed, axis change, NSFW revoke) runs through one total function: the is_nsfw axis is decided first (pinned value, else the current/seeded value while it still resolves — the mode outranks the other axes and never follows option order), then the remaining axes keep their values where the combination still resolves and hop to the first option in fennec’s authored order where it doesn’t. Without this the form would show one variant’s price and dispatch a tuple sirloin rejects. There is no authored default anymore: defaultValue isn’t mapped to the frontend, and fennec’s option order is the default.
  • Aspect ratio stays a plain contract input — never an axis. Seeds don’t carry it; the reuse dialog mirrors the source media’s ratio into scalarInputs and drops a value outside the contract’s enum in favor of the default.

useFacetedSurface(product, { seed, count, nsfwAllowed }) bundles the full stack for one product — families query, resolveProductFamily, the form, dispatch (useFacetedGenerate), and derived keys — so every faceted surface runs the identical spine.

The generic stream itself is selected by genericControlDescriptors (descriptors.ts): everything the contract declares beyond the dedicated controls (input_model → ModelPicker, is_nsfw → NsfwToggle, aspect_ratio → FormatSelector), minus text/character fields (the surface owns the composer and character selection).

7. Invariants & gotchas

  • The field set is the union and is static. Axis changes never add/remove fields — they only change which workflow resolves (and buildPayload prunes inputs to that variant). Don’t drive field visibility off axes.
  • is_nsfw is an axis, derived via deriveIsNsfw(selectedAxes); the authoritative rating comes from the resolved workflow server-side, never the toggle. NSFW entitlement gating belongs in the host page, not in WorkflowControls. The 18+ toggle’s disabled is family-wide — it greys out only when the family has no available coverage of the other mode at all; a per-model gap hops the model on flip instead.
  • Form state seeds on item change, keyed by item.id (React’s adjust-state-on-prop-change, no effect/flash). A background refetch with the same id won’t wipe the form.
  • descriptors is keyed by descriptor.key in WorkflowControls; axis keys and input keys must not collide within one item.

See also

  • Generation Families — the concept: families, axes, tuples, members, and how a pick resolves to a workflow.
  • Create Dialog Routing — how the create modal decides between the legacy dialog and the faceted twin, and how a product converts to workflows.