Module settings: image generation
What this solves
Information architecture: image generation carries two top-level modes, and inside edit, six operations, each swapping in its own field set with its own defaults and cross-field resets. Enhancement-product hand-rolled every branch as local
useOptions state with a wrap side-effect to recompute defaults on mode change; speedway has no image generation module at all, so there is only the one implementation to reproduce, not two to reconcile. SchemaForm expresses the whole tree as one discriminated oneOf (x-discriminator: mode), with x-default-from covering the sticky recolour-swatch and output-field defaults that used to live in bespoke hooks.Use it when
- A module's settings branch into mutually exclusive modes with a different field set per branchenhancement-product's Generate vs Edit mode cards, and Edit's own six-operation sub-picker (image-gen/mode-chooser.tsx)
- A field's default should track another field's value until the person overrides itenhancement-product's recolour swatch default per target, and the output-field name that follows the chosen operation
Rendered
Fixture data; check both themes.Viewer
none
patterns/module-image-generation/example.tsxCopy this into a fresh route and it renders as above.
"use client";import { Button, Card, EmptyState, SchemaForm, type SchemaFormHandle, type SchemaFormValidity, type XTier } from "@versable-git/ui";import { useRef, useState } from "react";import fixture from "../../../../../../docs/plan/65-schema-form-fixtures/image-generation.params.schema.json";import { FILE_CONTEXT, resolveModuleOptions } from "../../components/schema-form/examples/module-stubs";// The square aside SchemaForm renders beside the form at 1280 and below it at// 375; the console owns the real preview, this is the placeholder the fixture// stands in for. "ready" is a faked result, never an actual generated image.function PreviewPane({ state }: { state: "empty" | "ready" }) { return ( <Card noAnimate className="flex aspect-square w-full max-w-[280px] flex-col items-center justify-center lg:w-[280px]"> {state === "ready" ? ( <EmptyState Icon="Image" iconCircle title="Preview ready" description="A generated image would render here." compact /> ) : ( <EmptyState Icon="Image" title="Preview appears here after Run" compact /> )} </Card> );}// The image module's settings surface as the console would ship it: two// modes and six nested edit operations over the schema in spec section 5.2,// with the two slots the schema itself cannot supply, the preview pane and// the "ran fine, produced nothing" signal, stood in with a stateful stub.export function ModuleImageGeneration() { const ref = useRef<SchemaFormHandle>(null); const [value, setValue] = useState<Record<string, unknown>>({ mode: "generate" }); const [tier, setTier] = useState<XTier>("customer"); const [validity, setValidity] = useState<SchemaFormValidity | null>(null); const [formMode, setFormMode] = useState<"edit" | "review">("edit"); const [previewState, setPreviewState] = useState<"empty" | "ready">("empty"); const [status, setStatus] = useState<{ tone: "warning"; text: string } | undefined>(); const [runCount, setRunCount] = useState(0); // A real preview call sometimes finishes without producing anything usable // (a bad prompt, a mode with nothing to show); this fakes that outcome on // every second run so the warning-tier status line has something to show. function handleRun() { const out = ref.current?.submit(); if (!out) return; const next = runCount + 1; setRunCount(next); if (next % 2 === 0) { setStatus({ tone: "warning", text: "Preview ran and produced nothing usable. Try a different prompt or mode." }); setPreviewState("empty"); } else { setStatus(undefined); setPreviewState("ready"); } } return ( <div className="flex flex-col gap-4"> <div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center gap-2"> <span className="text-base-content/65 text-xs">Viewer</span> {(["customer", "admin"] as const).map((t) => ( <Button key={t} size="xs" variant={tier === t ? "solid" : "ghost"} color="primary" shade onClick={() => setTier(t)}> {t.charAt(0).toUpperCase() + t.slice(1)} </Button> ))} </div> <Button size="xs" variant="ghost" color="primary" shade onClick={() => setFormMode(formMode === "edit" ? "review" : "edit")}> {formMode === "edit" ? "Review" : "Edit"} </Button> </div> <SchemaForm ref={ref} schema={fixture} value={value} onChange={setValue} mode={formMode} tier={tier} settings={{ module_version: "v3" }} context={FILE_CONTEXT} resolveOptions={resolveModuleOptions} aside={<PreviewPane state={previewState} />} status={status} onValidityChange={setValidity} onEditSection={() => setFormMode("edit")} /> {formMode === "edit" && ( <div className="flex items-center gap-2"> <Button size="sm" color="primary" onClick={handleRun}> Run preview </Button> {validity && !validity.isValid && ( <span className="text-base-content/65 text-xs">{validity.pending ? "Waiting for options to load" : `${validity.errors.length} to fix before it can run`}</span> )} </div> )} </div> );}Where it ships
enhancement-product/frontend/src/app/jobs/v2/pages/s2ig-apply-image-gen.tsx:90-401The two-card mode chooser (image-gen/mode-chooser.tsx:15-54) and every mode's sub-form, hand-composed from local useOptions state; the schema's mode/operation discriminators reproduce this branch structure.enhancement-product/frontend/src/app/jobs/v2/components/image-gen/image-recolor.module.tsx:79-111The wrap hook that resets the recolour swatch default per target when mode or swatchSourceType changes; x-default-from on swatch and output_field covers the same sticky-default behaviour declaratively.docs/plan/65-schema-form-spec.md section 5.2The schema shape this fixture renders whole: the discriminated oneOf, the watermark nested group, and the six-operation edit branch.
App-specific: The fixture renders the form alone. Per spec 5.2's Slots, the console must supply the image preview pane beside it (an aside, stood in here with a placeholder) and the 'ran fine, produced nothing' outcome from its own preview call, which is the app's to raise through SchemaForm's status prop as a warning, not an error; SchemaForm has no opinion on when a generation attempt actually produced something.