SpectreSkills
← All skills

form-definition

v1.0.0

Define the form document an author edits and a renderer draws — field catalog, widths, submission keys, steps, and conditional rules. Trigger when modeling forms, adding a field type, or sharing one form shape across a builder and a public embed.

Formsformsschemafields
Install
npx @spectre-apps/skills add form-definition

Writes .claude/skills/form-definition/SKILL.md. Add --user to install globally.

What it does

Form definition

A form is a document. The builder edits it, the preview draws it, and the public embed fetches the published copy. Host chrome — admin UI kit, theme, commerce platform — stays outside this document so the same form can move to another framework.

Keep the definition in a package with no UI dependencies. Prisma rows, CMS documents, and API JSON all coerce into this shape.

The form

type FormStatus = "DRAFT" | "LIVE" | "ARCHIVED";
type FieldWidth = 100 | 50 | 33 | 25;

interface FormDefinition {
  id: string;
  name: string;
  status: FormStatus;
  heading: string;
  subheading: string;
  buttonLabel: string;
  buttonAlign: "left" | "center";
  successMessage: string;
  /** Integration key, or null when the only target is an email notification. */
  destination: string | null;
  notifyOnSubmission: boolean;
  spamProtection: boolean;
  fields: FormFieldDefinition[];
  /** Ordered step titles. Omit or leave a single entry for a one-page form. */
  steps?: string[];
}

interface FormFieldDefinition {
  id: string;
  type: string;
  label: string;
  /** Submission key. Slug of the label until the author overrides it. */
  name: string;
  placeholder: string;
  required: boolean;
  width: FieldWidth;
  options: string[];
  /** 1-based. Ignored when the form has a single step. */
  step?: number;
  conditional?: ConditionalLogic | null;
}

The submit button lives on the form (buttonLabel, buttonAlign). It collects nothing, so it stays out of fields. Every consumer would otherwise special-case the last element.

Field catalog

One manifest per type, ordered the way the palette shows them. control is how the field draws, separate from type, so a new type that draws like an existing one (a URL that renders as a text input) needs no renderer change.

interface FormFieldManifest {
  type: string;
  label: string;          // "Short text", "Dropdown"
  tag: string;            // three letters: TXT, EML, SEL — stands in for an icon
  control:
    | "input" | "textarea" | "select" | "choice" | "checkbox"
    | "file" | "rating" | "slider" | "heading" | "hidden";
  hasOptions: boolean;
  supportsRequired: boolean;
  supportsPlaceholder: boolean;
  defaults: {
    label: string;
    placeholder: string;
    options: string[];
    required: boolean;
    width: FieldWidth;
  };
}

Start from this vocabulary. Names are stable identifiers; adding a type is a new manifest row, and renaming one is a migration.

TypeControlNotes
TEXT, EMAIL, PHONE, NUMBER, DATE, TIMEinput
TEXTAREAtextarea
SELECTselecthasOptions
RADIOchoicehasOptions
CHECKBOXcheckboxthe placeholder is the consent line
FILE_UPLOADfilesee Uploads
RATING, SLIDERrating, slider
HEADINGheadingdisplay only; supportsRequired: false
HIDDENhiddensubmits a value, draws nothing

An unknown type degrades to a text input. A form authored against a newer catalog still opens in an older build.

Names, widths, rows

  • name is the submission key: lowercase, underscore-separated, ASCII (toFieldName). Underscores survive spreadsheet headers and CRM property names. Suffix _2, _3 when the key is taken.
  • Changing the label updates name only until the author has edited the name.
  • Widths are percentages (100 | 50 | 33 | 25) so a row packer can add them. Three thirds are 33 and total 99 — treat anything up to 101 as a full row.
  • Pack fields into rows: a full-width field is its own row; narrower fields accumulate until the next would overflow. Renderers emit these rows. Each row records the index of its first field in the flat array so a drop can become an absolute insertion point.

Conditional rules

A field may show or hide from the current values of other fields. The builder preview and the public embed evaluate the same object.

interface ConditionalLogic {
  enabled: boolean;
  operator: "AND" | "OR";
  action: "show" | "hide";
  conditions: Array<{
    fieldName: string;
    operator:
      | "equals" | "not_equals" | "contains" | "not_contains"
      | "greater_than" | "less_than" | "is_empty" | "is_not_empty";
    value: string | number | boolean | null;
  }>;
}

Offer only the operators that make sense for the watched field's type. A rule that references a missing field is inactive, and the field stays in its default visibility.

Validation

Return warnings. A half-built form is a normal intermediate state, and refusing to save one loses work. The caller decides what to surface and what to block publish on.

CodeWhen
empty-labellabel is blank
empty-namesubmission key is blank (hidden fields included — the key is the point)
duplicate-nametwo fields submit as the same key
options-requireda select or radio has no options
no-fieldsthe form has no fields
no-submit-targetno destination and email notification is off

On save, fill any missing name from the label. That pass is idempotent.

Uploads

A file field stores a reference, not bytes. The browser requests a presigned PUT scoped to that file, uploads straight to object storage, and the submission records the resulting id. The form POST stays small.

Locales

A locale is another FormDefinition for the same form id: same field ids and names, translated labels, placeholders, options, heading, and button. Submissions record which locale was used. Detecting locales is the host's job.

Pair with form-builder-canvas to edit this document, form-preview and form-web-component to draw it, and form-integrations for destination.