Zum Inhalt springen
UI Kit
Accent

Form Builder

Form-Builder-Screens. Felder hinzufügen, ordnen und einstellen, Logik für Folgefragen, Live-Vorschau auf Desktop und Telefon, Antworten mit Übersicht und Tabelle und Veröffentlichen mit Link, Einbettung und QR-Code.

Eine von 3 Components in Automation und Builder

5 Variants

Variants

Builder

Feldtypen zum Hinzufügen, das Formular, wie es aussehen wird, mit Wählen, Verschieben, Kopieren und Entfernen, und die Einstellungen des gewählten Felds. Telefone öffnen sie unter dem Feld.

Im Studio öffnen

Logik

Wenn eine Antwort passt, spätere Fragen zeigen, dazu eine Liste, die abblendet, was eine Person bei den ausprobierten Antworten nicht sieht.

Im Studio öffnen

Live-Vorschau

Das echte Formular mit Prüfung, Logik und Dank, breit oder im Telefon gezeigt, mit einer Linie, die sich mit jeder Antwort füllt.

Im Studio öffnen

Antworten

Summe, heute, abgeschlossener Anteil und Durchschnitt, Balken je Antwort und eine durchsuchbare Tabelle, die auf dem Telefon zu Karten wird.

Im Studio öffnen

Veröffentlichen

Link und Einbettungscode zum Kopieren, ein QR-Code, Regeln zum Schließen und ein Veröffentlichen, das den Entwurf live schaltet.

Im Studio öffnen

Details

Was Form Builder mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/form-builder.tsx
Bringt mit
utilskitbuttoniconswitchsidebar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 84 eigene Texte

Props

Die Schnittstelle FormBuilderProps aus components/ui/form-builder.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    FormBuilderVariant

    builderlogicpreviewresponsespublish

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlePflicht
    string
  • subtitleoptional
    string
  • formTitleoptional
    string

    Title and intro shown on the form itself

  • formIntrooptional
    string
  • fieldsoptional
    FormField[]
  • logicoptional
    LogicRule[]
  • responsesoptional
    FormResponse[]
  • urloptional
    string

    publish: the public address of the form

  • nowoptional
    string

    ISO date and time that counts as now

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on save, publish and submit. Without it the block simulates the request.

Eingebaute Texte

Diese Texte schreibt Form Builder selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 84 Texte anzeigen
textEnglisch Short textDeutsch Kurzer Text
emailEnglisch EmailDeutsch E-Mail
longEnglisch Long textDeutsch Langer Text
choiceEnglisch Single choiceDeutsch Einfachauswahl
checkboxEnglisch Multiple choiceDeutsch Mehrfachauswahl
ratingEnglisch RatingDeutsch Bewertung
dateEnglisch DateDeutsch Datum
numberEnglisch NumberDeutsch Zahl
fieldsEnglisch FieldsDeutsch Felder
addFieldEnglisch Add fieldDeutsch Feld hinzufügen
addTypeEnglisch Add {type}Deutsch {type} hinzufügen
newFieldEnglisch New questionDeutsch Neue Frage
moveUpEnglisch Move {name} upDeutsch {name} nach oben
moveDownEnglisch Move {name} downDeutsch {name} nach unten
duplicateEnglisch Duplicate {name}Deutsch {name} duplizieren
removeEnglisch Remove {name}Deutsch {name} entfernen
settingsEnglisch Field settingsDeutsch Feldeinstellungen
pickFieldEnglisch Pick a field to change it.Deutsch Wähle ein Feld, um es zu ändern.
labelEnglisch QuestionDeutsch Frage
helpEnglisch Help textDeutsch Hilfetext
placeholderEnglisch PlaceholderDeutsch Platzhalter
requiredEnglisch RequiredDeutsch Pflichtfeld
requiredTextEnglisch The form cannot be sent without an answer.Deutsch Ohne Antwort lässt sich das Formular nicht senden.
optionsEnglisch OptionsDeutsch Optionen
addOptionEnglisch Add optionDeutsch Option hinzufügen
optionEnglisch Option {n}Deutsch Option {n}
removeOptionEnglisch Remove option {n}Deutsch Option {n} entfernen
formEnglisch FormDeutsch Formular
logicEnglisch LogicDeutsch Logik
logicTextEnglisch Show questions only when an earlier answer fits. Hidden questions are never required.Deutsch Fragen nur zeigen, wenn eine frühere Antwort passt. Versteckte Fragen sind nie Pflicht.
whenEnglisch WhenDeutsch Wenn
isEnglisch isDeutsch ist
showEnglisch showDeutsch zeige
addRuleEnglisch Add ruleDeutsch Regel hinzufügen
removeRuleEnglisch Remove ruleDeutsch Regel entfernen
noLogicEnglisch No rules yet. Every question is shown to everyone.Deutsch Noch keine Regeln. Alle sehen jede Frage.
pathForEnglisch What people seeDeutsch Was Leute sehen
pathHintEnglisch Pick an answer to see which questions follow.Deutsch Wähle eine Antwort, um zu sehen, welche Fragen folgen.
alwaysShownEnglisch AlwaysDeutsch Immer
shownIfEnglisch If {field} is {value}Deutsch Wenn {field} {value} ist
desktopEnglisch DesktopDeutsch Desktop
phoneEnglisch PhoneDeutsch Telefon
deviceEnglisch DeviceDeutsch Gerät
submitEnglisch SendDeutsch Senden
sendingEnglisch Sending…Deutsch Wird gesendet…
thanksEnglisch Thank you, your answer is in.Deutsch Danke, deine Antwort ist da.
thanksTextEnglisch We read every answer and get back within two working days.Deutsch Wir lesen jede Antwort und melden uns innerhalb von zwei Werktagen.
againEnglisch Fill in againDeutsch Erneut ausfüllen
requiredErrorEnglisch Please answer this question.Deutsch Bitte beantworte diese Frage.
emailErrorEnglisch Enter an email address like name@example.com.Deutsch Gib eine E-Mail-Adresse wie name@beispiel.de ein.
responsesEnglisch ResponsesDeutsch Antworten
answersNEnglisch {n} answersDeutsch {n} Antworten
totalEnglisch ResponsesDeutsch Antworten
todayEnglisch TodayDeutsch Heute
completionEnglisch FinishedDeutsch Abgeschlossen
avgRatingEnglisch Average ratingDeutsch Durchschnitt
summaryEnglisch SummaryDeutsch Übersicht
tableEnglisch All answersDeutsch Alle Antworten
downloadEnglisch Download CSVDeutsch CSV herunterladen
searchEnglisch Search answersDeutsch Antworten suchen
noAnswerEnglisch ·Deutsch ·
publishEnglisch PublishDeutsch Veröffentlichen
publishedEnglisch LiveDeutsch Live
draftEnglisch DraftDeutsch Entwurf
linkEnglisch LinkDeutsch Link
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
embedEnglisch EmbedDeutsch Einbetten
embedTextEnglisch Paste this code where the form should appear.Deutsch Füge diesen Code dort ein, wo das Formular erscheinen soll.
qrEnglisch QR codeDeutsch QR-Code
qrTextEnglisch For posters, menus and counters.Deutsch Für Aushänge, Speisekarten und Theken.
acceptEnglisch Accept responsesDeutsch Antworten annehmen
acceptTextEnglisch Turn off to close the form, the link shows a short note.Deutsch Aus schließt das Formular, der Link zeigt einen kurzen Hinweis.
closeOnEnglisch Close on a dateDeutsch An einem Datum schließen
limitEnglisch Limit responsesDeutsch Antworten begrenzen
limitNEnglisch Up to {n} responsesDeutsch Bis zu {n} Antworten
notifyEnglisch Email me for every responseDeutsch Bei jeder Antwort per E-Mail benachrichtigen
goLiveEnglisch Publish formDeutsch Formular veröffentlichen
goingEnglisch Publishing…Deutsch Wird veröffentlicht…
liveEnglisch The form is live. Share the link to start collecting answers.Deutsch Das Formular ist live. Teile den Link, um Antworten zu sammeln.
options_Englisch SettingsDeutsch Einstellungen
saveLogicEnglisch Save logicDeutsch Logik speichern
logicSavedEnglisch Logic savedDeutsch Logik gespeichert
timeEnglisch TimeDeutsch Zeit

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Form Builder reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-current, aria-describedby, aria-invalid, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, group, radio, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.285 Zeilen
components/ui/form-builder.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
import { useScrollFade } from '@/components/ui/motion';

export type FormBuilderVariant = 'builder' | 'logic' | 'preview' | 'responses' | 'publish';
export type FieldType = 'text' | 'email' | 'long' | 'choice' | 'checkbox' | 'rating' | 'date' | 'number';

export interface FormField {
  id: string;
  type: FieldType;
  label: string;
  help?: string;
  required?: boolean;
  placeholder?: string;
  /** choice, checkbox */
  options?: string[];
}

export interface LogicRule {
  id: string;
  /** Field id that decides */
  when: string;
  /** Answer that triggers it */
  equals: string;
  /** Field ids that show up */
  show: string[];
}

export interface FormResponse {
  id: string;
  /** ISO date and time */
  time: string;
  /** Answer per field id */
  answers: Record<string, string>;
}

export interface FormBuilderProps {
  variant?: FormBuilderVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** Title and intro shown on the form itself */
  formTitle?: string;
  formIntro?: string;
  fields?: FormField[];
  logic?: LogicRule[];
  responses?: FormResponse[];
  /** publish: the public address of the form */
  url?: string;
  /** ISO date and time that counts as now */
  now?: string;
  /** Called on save, publish and submit. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    text: 'Short text',
    email: 'Email',
    long: 'Long text',
    choice: 'Single choice',
    checkbox: 'Multiple choice',
    rating: 'Rating',
    date: 'Date',
    number: 'Number',
    fields: 'Fields',
    addField: 'Add field',
    addType: 'Add {type}',
    newField: 'New question',
    moveUp: 'Move {name} up',
    moveDown: 'Move {name} down',
    duplicate: 'Duplicate {name}',
    remove: 'Remove {name}',
    settings: 'Field settings',
    pickField: 'Pick a field to change it.',
    label: 'Question',
    help: 'Help text',
    placeholder: 'Placeholder',
    required: 'Required',
    requiredText: 'The form cannot be sent without an answer.',
    options: 'Options',
    addOption: 'Add option',
    option: 'Option {n}',
    removeOption: 'Remove option {n}',
    form: 'Form',
    logic: 'Logic',
    logicText: 'Show questions only when an earlier answer fits. Hidden questions are never required.',
    when: 'When',
    is: 'is',
    show: 'show',
    addRule: 'Add rule',
    removeRule: 'Remove rule',
    noLogic: 'No rules yet. Every question is shown to everyone.',
    pathFor: 'What people see',
    pathHint: 'Pick an answer to see which questions follow.',
    alwaysShown: 'Always',
    shownIf: 'If {field} is {value}',
    desktop: 'Desktop',
    phone: 'Phone',
    device: 'Device',
    submit: 'Send',
    sending: 'Sending…',
    thanks: 'Thank you, your answer is in.',
    thanksText: 'We read every answer and get back within two working days.',
    again: 'Fill in again',
    requiredError: 'Please answer this question.',
    emailError: 'Enter an email address like name@example.com.',
    responses: 'Responses',
    answersN: '{n} answers',
    total: 'Responses',
    today: 'Today',
    completion: 'Finished',
    avgRating: 'Average rating',
    summary: 'Summary',
    table: 'All answers',
    download: 'Download CSV',
    search: 'Search answers',
    noAnswer: '·',
    publish: 'Publish',
    published: 'Live',
    draft: 'Draft',
    link: 'Link',
    copy: 'Copy',
    copied: 'Copied',
    embed: 'Embed',
    embedText: 'Paste this code where the form should appear.',
    qr: 'QR code',
    qrText: 'For posters, menus and counters.',
    accept: 'Accept responses',
    acceptText: 'Turn off to close the form, the link shows a short note.',
    closeOn: 'Close on a date',
    limit: 'Limit responses',
    limitN: 'Up to {n} responses',
    notify: 'Email me for every response',
    goLive: 'Publish form',
    going: 'Publishing…',
    live: 'The form is live. Share the link to start collecting answers.',
    options_: 'Settings',
    saveLogic: 'Save logic',
    logicSaved: 'Logic saved',
    time: 'Time',
  },
  de: {
    text: 'Kurzer Text',
    email: 'E-Mail',
    long: 'Langer Text',
    choice: 'Einfachauswahl',
    checkbox: 'Mehrfachauswahl',
    rating: 'Bewertung',
    date: 'Datum',
    number: 'Zahl',
    fields: 'Felder',
    addField: 'Feld hinzufügen',
    addType: '{type} hinzufügen',
    newField: 'Neue Frage',
    moveUp: '{name} nach oben',
    moveDown: '{name} nach unten',
    duplicate: '{name} duplizieren',
    remove: '{name} entfernen',
    settings: 'Feldeinstellungen',
    pickField: 'Wähle ein Feld, um es zu ändern.',
    label: 'Frage',
    help: 'Hilfetext',
    placeholder: 'Platzhalter',
    required: 'Pflichtfeld',
    requiredText: 'Ohne Antwort lässt sich das Formular nicht senden.',
    options: 'Optionen',
    addOption: 'Option hinzufügen',
    option: 'Option {n}',
    removeOption: 'Option {n} entfernen',
    form: 'Formular',
    logic: 'Logik',
    logicText: 'Fragen nur zeigen, wenn eine frühere Antwort passt. Versteckte Fragen sind nie Pflicht.',
    when: 'Wenn',
    is: 'ist',
    show: 'zeige',
    addRule: 'Regel hinzufügen',
    removeRule: 'Regel entfernen',
    noLogic: 'Noch keine Regeln. Alle sehen jede Frage.',
    pathFor: 'Was Leute sehen',
    pathHint: 'Wähle eine Antwort, um zu sehen, welche Fragen folgen.',
    alwaysShown: 'Immer',
    shownIf: 'Wenn {field} {value} ist',
    desktop: 'Desktop',
    phone: 'Telefon',
    device: 'Gerät',
    submit: 'Senden',
    sending: 'Wird gesendet…',
    thanks: 'Danke, deine Antwort ist da.',
    thanksText: 'Wir lesen jede Antwort und melden uns innerhalb von zwei Werktagen.',
    again: 'Erneut ausfüllen',
    requiredError: 'Bitte beantworte diese Frage.',
    emailError: 'Gib eine E-Mail-Adresse wie name@beispiel.de ein.',
    responses: 'Antworten',
    answersN: '{n} Antworten',
    total: 'Antworten',
    today: 'Heute',
    completion: 'Abgeschlossen',
    avgRating: 'Durchschnitt',
    summary: 'Übersicht',
    table: 'Alle Antworten',
    download: 'CSV herunterladen',
    search: 'Antworten suchen',
    noAnswer: '·',
    publish: 'Veröffentlichen',
    published: 'Live',
    draft: 'Entwurf',
    link: 'Link',
    copy: 'Kopieren',
    copied: 'Kopiert',
    embed: 'Einbetten',
    embedText: 'Füge diesen Code dort ein, wo das Formular erscheinen soll.',
    qr: 'QR-Code',
    qrText: 'Für Aushänge, Speisekarten und Theken.',
    accept: 'Antworten annehmen',
    acceptText: 'Aus schließt das Formular, der Link zeigt einen kurzen Hinweis.',
    closeOn: 'An einem Datum schließen',
    limit: 'Antworten begrenzen',
    limitN: 'Bis zu {n} Antworten',
    notify: 'Bei jeder Antwort per E-Mail benachrichtigen',
    goLive: 'Formular veröffentlichen',
    going: 'Wird veröffentlicht…',
    live: 'Das Formular ist live. Teile den Link, um Antworten zu sammeln.',
    options_: 'Einstellungen',
    saveLogic: 'Logik speichern',
    logicSaved: 'Logik gespeichert',
    time: 'Zeit',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

type P = Omit<FormBuilderProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; fields: FormField[]; now: string };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: FormBuilderProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
  if (onAction) await onAction(action, data);
  else await wait(prefersReducedMotion() ? 0 : ms);
}

const TYPE_ICON: Record<FieldType, IconName> = { text: 'type', email: 'at-sign', long: 'align-left', choice: 'circle', checkbox: 'check-square', rating: 'star', date: 'calendar', number: 'hash' };
const TYPES: FieldType[] = ['text', 'email', 'long', 'choice', 'checkbox', 'rating', 'date', 'number'];
const control = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-[14px]';

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
  return (
    <section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      {(title || action) && (
        <div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
          <div className="min-w-0">
            {title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
            {description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
          </div>
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useGlide<HTMLDivElement>(value);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
            value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
          <span className="relative inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

/** One field as the person filling in sees it. `value` and `onChange` make it live, without them it is a still preview. */
function FieldView({ f, t, value = '', onChange, error, idBase }: { f: FormField; t: Texts; value?: string; onChange?: (v: string) => void; error?: string; idBase: string }) {
  const id = `${idBase}-${f.id}`;
  const still = !onChange;
  const label = (
    <span className="flex items-baseline gap-1 text-[14px] font-medium text-foreground">
      {f.label}
      {f.required && <span aria-hidden className="text-destructive">*</span>}
    </span>
  );
  const help = f.help && <span className="mt-0.5 block text-[12.5px] text-muted-foreground">{f.help}</span>;
  const err = error && (
    <span role="alert" className="kitm-in mt-1.5 flex items-center gap-1.5 text-[12.5px] font-medium text-destructive">
      <Icon name="alert-circle" size={14} />
      {error}
    </span>
  );
  const ring = error ? 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]' : '';
  if (f.type === 'choice' || f.type === 'checkbox') {
    const picked = value ? value.split('|') : [];
    return (
      <fieldset aria-describedby={error ? `${id}-e` : undefined}>
        <legend>{label}</legend>
        {help}
        <div className="mt-2 grid gap-2 sm:grid-cols-2">
          {(f.options ?? []).map((o) => {
            const on = picked.includes(o);
            return (
              <label key={o} className={cn('relative flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-[14px] transition-[border-color,background-color] duration-200', still ? 'cursor-default' : 'cursor-pointer', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border hover:border-border-strong')}>
                <input type={f.type === 'choice' ? 'radio' : 'checkbox'} name={id} disabled={still} checked={on} onChange={() => onChange?.(f.type === 'choice' ? o : (on ? picked.filter((x) => x !== o) : [...picked, o]).join('|'))} className="peer sr-only" />
                <span aria-hidden className={cn('grid size-[18px] shrink-0 place-items-center border transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', f.type === 'choice' ? 'rounded-full' : 'rounded-[5px]', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
                  {on && (f.type === 'choice' ? <span className="size-[7px] rounded-full bg-primary-foreground" /> : <Icon name="check" size={12} strokeWidth={2.6} />)}
                </span>
                <span className="text-foreground">{o}</span>
              </label>
            );
          })}
        </div>
        {err}
      </fieldset>
    );
  }
  if (f.type === 'rating') {
    const n = Number(value) || 0;
    return (
      <fieldset>
        <legend>{label}</legend>
        {help}
        <div className="mt-2 flex gap-1.5" role="radiogroup" aria-label={f.label}>
          {[1, 2, 3, 4, 5].map((k) => (
            <button key={k} type="button" role="radio" aria-checked={n === k} aria-label={`${k} / 5`} disabled={still} onClick={() => onChange?.(String(k))} className={cn('grid size-11 place-items-center rounded-lg border transition-[transform,background-color,border-color] duration-200 active:scale-90', k <= n ? 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] text-warning-foreground' : 'border-border text-subtle-foreground hover:text-foreground', still && 'cursor-default')}>
              <Icon name="star" size={18} className={k <= n ? '[&_path]:fill-current' : ''} />
            </button>
          ))}
        </div>
        {err}
      </fieldset>
    );
  }
  const Tag = f.type === 'long' ? 'textarea' : 'input';
  return (
    <label className="block" htmlFor={id}>
      {label}
      {help}
      <Tag
        id={id}
        readOnly={still}
        tabIndex={still ? -1 : undefined}
        type={f.type === 'email' ? 'email' : f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
        rows={f.type === 'long' ? 4 : undefined}
        placeholder={f.placeholder}
        value={value}
        aria-invalid={Boolean(error)}
        onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange?.(e.target.value)}
        className={cn(control, 'mt-2', f.type === 'long' && 'h-auto py-2.5 sm:h-auto', ring, still && 'pointer-events-none')}
      />
      {err}
    </label>
  );
}

/* ------------------------------------------------------------------ */
/* builder                                                             */
/* ------------------------------------------------------------------ */

function Builder({ t, title, subtitle, fields: start, formTitle, formIntro }: P) {
  const id = useId();
  const [fields, setFields] = useState(start);
  const [sel, setSel] = useState(start[0]?.id ?? '');
  const [fresh, setFresh] = useState('');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useRef<HTMLOListElement>(null);
  const [rowRef, fade] = useScrollFade<HTMLUListElement>();
  const f = fields.find((x) => x.id === sel);
  const set = (patch: Partial<FormField>) => setFields((l) => l.map((x) => (x.id === sel ? { ...x, ...patch } : x)));
  const add = (type: FieldType) => {
    const n: FormField = { id: `f${Date.now()}`, type, label: t.newField, options: type === 'choice' || type === 'checkbox' ? [fill(t.option, { n: 1 }), fill(t.option, { n: 2 })] : undefined };
    const at = Math.max(0, fields.findIndex((x) => x.id === sel)) + 1;
    setFields((l) => [...l.slice(0, at), n, ...l.slice(at)]);
    setSel(n.id);
    setFresh(n.id);
    setMsg(fill(t.addType, { type: t[type] }));
  };
  const move = (i: number, d: number) => {
    const j = i + d;
    if (j < 0 || j >= fields.length) return;
    setFields((l) => {
      const c = [...l];
      [c[i], c[j]] = [c[j], c[i]];
      return c;
    });
  };
  // Fields glide to their new place when moved
  const before = useRef<Map<string, number>>(new Map());
  if (typeof window !== 'undefined' && listRef.current) before.current = new Map(Array.from(listRef.current.children, (el) => [(el as HTMLElement).dataset.id ?? '', el.getBoundingClientRect().top]));
  useIso(() => {
    if (!listRef.current || prefersReducedMotion()) return;
    Array.from(listRef.current.children).forEach((el) => {
      const a = before.current.get((el as HTMLElement).dataset.id ?? '');
      const b = el.getBoundingClientRect().top;
      if (a !== undefined && Math.abs(a - b) > 1) el.animate([{ transform: `translateY(${a - b}px)` }, { transform: 'none' }], { duration: 420, easing: KITM_EASE });
    });
  }, [fields.map((x) => x.id).join()]);
  const settings = f && (
    <div key={f.id} className="kitm-in grid gap-4">
      <p className="flex items-center gap-2 text-[12.5px] font-medium text-muted-foreground">
        <Icon name={TYPE_ICON[f.type]} size={14} />
        {t[f.type]}
      </p>
      <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-l`}>
        {t.label}
        <input id={`${id}-l`} value={f.label} onChange={(e) => set({ label: e.target.value })} className={control} />
      </label>
      <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-h`}>
        {t.help}
        <input id={`${id}-h`} value={f.help ?? ''} onChange={(e) => set({ help: e.target.value })} className={control} />
      </label>
      {(f.type === 'text' || f.type === 'email' || f.type === 'long' || f.type === 'number') && (
        <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-p`}>
          {t.placeholder}
          <input id={`${id}-p`} value={f.placeholder ?? ''} onChange={(e) => set({ placeholder: e.target.value })} className={control} />
        </label>
      )}
      {f.options && (
        <div>
          <p className="text-[13px] font-medium text-foreground">{t.options}</p>
          <ul className="mt-1.5 grid gap-1.5">
            {f.options.map((o, i) => (
              <li key={i} className="kitm-in flex items-center gap-2">
                <span aria-hidden className={cn('size-3.5 shrink-0 border border-border-strong', f.type === 'choice' ? 'rounded-full' : 'rounded-[4px]')} />
                <input aria-label={fill(t.option, { n: i + 1 })} value={o} onChange={(e) => set({ options: f.options!.map((x, k) => (k === i ? e.target.value : x)) })} className={cn(control, 'sm:h-9')} />
                <button type="button" aria-label={fill(t.removeOption, { n: i + 1 })} disabled={f.options!.length < 3} onClick={() => set({ options: f.options!.filter((_, k) => k !== i) })} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive disabled:opacity-30 sm:size-9">
                  <Icon name="x" size={15} />
                </button>
              </li>
            ))}
          </ul>
          <Button variant="ghost" size="sm" iconLeft="plus" className="mt-1" onClick={() => set({ options: [...f.options!, fill(t.option, { n: f.options!.length + 1 })] })}>
            {t.addOption}
          </Button>
        </div>
      )}
      <Switch checked={Boolean(f.required)} onCheckedChange={(v) => set({ required: v })} label={t.required} description={t.requiredText} className="border-t border-border pt-4" />
    </div>
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[minmax(0,1fr)_17rem] xl:grid-cols-[13rem_minmax(0,1fr)_19rem] lg:items-start">
        {/* Wide screens: the field types as a column. Below 1280 px, where the app sidebar takes room, a row of chips above the form. */}
        <nav aria-label={t.addField} className="kitm-up md:col-span-2 xl:col-span-1 xl:sticky xl:top-6">
          <p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.addField}</p>
          <ul ref={rowRef} className={cn('kit-no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 xl:mx-0 xl:grid xl:gap-1 xl:overflow-visible xl:px-0 xl:[mask-image:none]', fade)}>
            {TYPES.map((ty) => (
              <li key={ty} className="shrink-0">
                <button type="button" aria-label={fill(t.addType, { type: t[ty] })} onClick={() => add(ty)} className="group flex h-11 w-full items-center gap-2.5 whitespace-nowrap rounded-lg border border-border bg-card px-3 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-[border-color,transform] duration-200 hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] active:scale-[0.98] xl:h-10">
                  <Icon name={TYPE_ICON[ty]} size={15} className="shrink-0 text-muted-foreground transition-colors group-hover:text-primary" />
                  {t[ty]}
                  <Icon name="plus" size={14} className="ms-auto hidden shrink-0 text-subtle-foreground xl:block" />
                </button>
              </li>
            ))}
          </ul>
        </nav>
        {/* The form as it will look, each field a block that can be picked */}
        <section aria-label={t.form} className="kitm-up [--kitm-d:80ms] min-w-0 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <header className="border-b border-border px-5 py-5 sm:px-7">
            <h2 className="text-[20px] font-semibold tracking-[-0.02em] text-foreground">{formTitle}</h2>
            {formIntro && <p className="mt-1 text-pretty text-[14px] text-muted-foreground">{formIntro}</p>}
          </header>
          <ol ref={listRef} className="grid gap-2 p-3 sm:p-4">
            {fields.map((x, i) => (
              <li key={x.id} data-id={x.id} className={cn(x.id === fresh && 'kitm-pop')}>
                <div
                  role="group"
                  tabIndex={0}
                  aria-label={`${t[x.type]}: ${x.label}`}
                  aria-current={x.id === sel ? 'true' : undefined}
                  onClick={() => setSel(x.id)}
                  onKeyDown={(e) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      e.preventDefault();
                      setSel(x.id);
                    } else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
                      e.preventDefault();
                      move(i, e.key === 'ArrowUp' ? -1 : 1);
                    }
                  }}
                  className={cn('group relative rounded-lg border p-4 outline-none transition-[border-color,background-color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:p-5', x.id === sel ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))]' : 'cursor-pointer border-transparent hover:border-border hover:bg-[color-mix(in_oklab,var(--foreground)_2%,transparent)]')}
                >
                  {x.id === sel && <span aria-hidden className="absolute inset-y-3 start-0 w-0.5 rounded-full bg-primary" />}
                  <FieldView f={x} t={t} idBase={`${id}-b`} />
                  <div className={cn('kitm-in mt-3 items-center justify-end gap-0.5 [--kitm-t:.3s]', x.id === sel ? 'flex' : 'hidden max-md:flex')}>
                    {[
                      { icon: 'arrow-up' as IconName, label: fill(t.moveUp, { name: x.label }), on: () => move(i, -1), off: i === 0 },
                      { icon: 'arrow-down' as IconName, label: fill(t.moveDown, { name: x.label }), on: () => move(i, 1), off: i === fields.length - 1 },
                      {
                        icon: 'copy' as IconName,
                        label: fill(t.duplicate, { name: x.label }),
                        on: () => {
                          const c = { ...x, id: `f${Date.now()}` };
                          setFields((l) => [...l.slice(0, i + 1), c, ...l.slice(i + 1)]);
                          setSel(c.id);
                          setFresh(c.id);
                        },
                      },
                      {
                        icon: 'trash' as IconName,
                        label: fill(t.remove, { name: x.label }),
                        on: () => {
                          setFields((l) => l.filter((y) => y.id !== x.id));
                          setSel(fields[i + 1]?.id ?? fields[i - 1]?.id ?? '');
                        },
                      },
                    ].map((b) => (
                      <button
                        key={b.icon}
                        type="button"
                        aria-label={b.label}
                        disabled={b.off}
                        onClick={(e) => {
                          e.stopPropagation();
                          b.on();
                        }}
                        className={cn('grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8', b.icon === 'trash' && 'hover:text-destructive')}
                      >
                        <Icon name={b.icon} size={15} />
                      </button>
                    ))}
                  </div>
                  {/* Phones: the settings open right under the picked field */}
                  {x.id === sel && <div className="mt-4 border-t border-border pt-4 md:hidden" onClick={(e) => e.stopPropagation()}>{settings}</div>}
                </div>
              </li>
            ))}
          </ol>
        </section>
        <aside aria-label={t.settings} className="kitm-up [--kitm-d:160ms] hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] md:block md:sticky md:top-6">
          <h2 className="border-b border-border px-4 py-3.5 text-[14px] font-semibold text-foreground sm:px-5">{t.settings}</h2>
          <div className="p-4 sm:p-5">{settings ?? <p className="text-[13px] text-muted-foreground">{t.pickField}</p>}</div>
        </aside>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* logic                                                               */
/* ------------------------------------------------------------------ */

function Logic({ t, title, subtitle, fields, logic = [], onAction }: P) {
  const [rules, setRules] = useState(logic);
  const [probe, setProbe] = useState<Record<string, string>>({});
  const [saved, setSaved] = useState(false);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const deciders = fields.filter((f) => f.type === 'choice' || f.type === 'rating');
  const label = (fid: string) => fields.find((f) => f.id === fid)?.label ?? fid;
  const ruleFor = (fid: string) => rules.find((r) => r.show.includes(fid));
  const visible = (fid: string) => {
    const r = ruleFor(fid);
    return !r || probe[r.when] === r.equals;
  };
  const set = (rid: string, patch: Partial<LogicRule>) => {
    setRules((l) => l.map((r) => (r.id === rid ? { ...r, ...patch } : r)));
    setSaved(false);
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
        <Card title={t.logic} description={t.logicText} className="kitm-up" bodyClassName="p-0 sm:p-0">
          <ul className="divide-y divide-border">
            {rules.map((r, i) => {
              const decider = fields.find((f) => f.id === r.when);
              const after = fields.slice(fields.findIndex((f) => f.id === r.when) + 1);
              return (
                <li key={r.id} className="kitm-up grid gap-3 px-4 py-4 sm:px-5" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
                  <div className="flex flex-wrap items-center gap-2 text-[13px]">
                    <span className="font-semibold text-foreground">{t.when}</span>
                    <span className="relative">
                      <select aria-label={t.when} value={r.when} onChange={(e) => set(r.id, { when: e.target.value, equals: fields.find((f) => f.id === e.target.value)?.options?.[0] ?? '' })} className={cn(control, 'w-auto max-w-[14rem] appearance-none pe-8 sm:h-9 sm:text-[13px]')}>
                        {deciders.map((f) => (
                          <option key={f.id} value={f.id}>
                            {f.label}
                          </option>
                        ))}
                      </select>
                      <Icon name="chevrons-up-down" size={13} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
                    </span>
                    <span className="text-muted-foreground">{t.is}</span>
                    <span className="relative">
                      <select aria-label={t.is} value={r.equals} onChange={(e) => set(r.id, { equals: e.target.value })} className={cn(control, 'w-auto max-w-[14rem] appearance-none pe-8 sm:h-9 sm:text-[13px]')}>
                        {(decider?.type === 'rating' ? ['1', '2', '3', '4', '5'] : decider?.options ?? []).map((o) => (
                          <option key={o} value={o}>
                            {o}
                          </option>
                        ))}
                      </select>
                      <Icon name="chevrons-up-down" size={13} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
                    </span>
                    <button type="button" aria-label={t.removeRule} onClick={() => setRules((l) => l.filter((x) => x.id !== r.id))} className="ms-auto grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-8">
                      <Icon name="trash" size={15} />
                    </button>
                  </div>
                  <div className="flex flex-wrap items-center gap-1.5">
                    <span className="me-1 text-[13px] font-semibold text-foreground">{t.show}</span>
                    {after.map((f) => {
                      const on = r.show.includes(f.id);
                      return (
                        <button key={f.id} type="button" aria-pressed={on} onClick={() => set(r.id, { show: on ? r.show.filter((x) => x !== f.id) : [...r.show, f.id] })} className={cn('inline-flex h-11 items-center sm:h-9 gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-medium transition-colors', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] text-foreground' : 'border-dashed border-border-strong text-muted-foreground hover:text-foreground')}>
                          <Icon name={on ? 'check' : 'plus'} size={13} />
                          {f.label}
                        </button>
                      );
                    })}
                  </div>
                </li>
              );
            })}
          </ul>
          {!rules.length && <p className="px-5 py-8 text-center text-[13.5px] text-muted-foreground">{t.noLogic}</p>}
          <div className="border-t border-border px-4 py-3 sm:px-5">
            <Button variant="ghost" size="sm" iconLeft="plus" disabled={!deciders.length} onClick={() => setRules((l) => [...l, { id: `r${Date.now()}`, when: deciders[0].id, equals: deciders[0].options?.[0] ?? '5', show: [] }])}>
              {t.addRule}
            </Button>
          </div>
        </Card>
        {/* What a person sees for the answers picked here */}
        <Card title={t.pathFor} description={t.pathHint} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
          <ol className="relative">
            {fields.map((f, i) => {
              const r = ruleFor(f.id);
              const on = visible(f.id);
              const decides = rules.some((x) => x.when === f.id);
              return (
                <li key={f.id} className={cn('relative border-b border-border px-4 py-3 transition-[opacity,background-color] duration-300 last:border-b-0 sm:px-5', on ? '' : 'bg-[color-mix(in_oklab,var(--foreground)_2%,transparent)] opacity-45')}>
                  <p className="flex items-center gap-2 text-[13.5px] font-medium text-foreground">
                    <span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted text-[11px] font-semibold tabular-nums text-muted-foreground">{i + 1}</span>
                    <span className="min-w-0 flex-1 truncate">{f.label}</span>
                    <Icon name={on ? 'eye' : 'eye-off'} size={14} className="shrink-0 text-subtle-foreground" />
                  </p>
                  <p className="mt-0.5 ps-8 text-[12px] text-muted-foreground">{r ? fill(t.shownIf, { field: label(r.when), value: r.equals }) : t.alwaysShown}</p>
                  {decides && (
                    <div className="mt-2 flex flex-wrap gap-1.5 ps-8">
                      {(f.type === 'rating' ? ['1', '2', '3', '4', '5'] : f.options ?? []).map((o) => (
                        <button key={o} type="button" aria-pressed={probe[f.id] === o} onClick={() => setProbe((p) => ({ ...p, [f.id]: p[f.id] === o ? '' : o }))} className={cn('h-11 min-w-11 rounded-md border px-2.5 text-[12px] sm:h-8 sm:min-w-8 sm:px-2 font-medium transition-colors', probe[f.id] === o ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                          {o}
                        </button>
                      ))}
                    </div>
                  )}
                </li>
              );
            })}
          </ol>
          <div className="border-t border-border p-4 sm:p-5">
            <Button
              className="w-full"
              size="sm"
              iconLeft={saved ? 'check' : undefined}
              onClick={async () => {
                await run(onAction, 'save-logic', { rules });
                setSaved(true);
              }}
            >
              {saved ? t.logicSaved : t.saveLogic}
            </Button>
          </div>
        </Card>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* preview                                                             */
/* ------------------------------------------------------------------ */

function Preview({ t, title, subtitle, fields, logic = [], formTitle, formIntro, onAction }: P) {
  const id = useId();
  const [device, setDevice] = useState<'desktop' | 'phone'>('desktop');
  const [vals, setVals] = useState<Record<string, string>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const shown = fields.filter((f) => {
    const r = logic.find((x) => x.show.includes(f.id));
    return !r || vals[r.when] === r.equals;
  });
  const answered = shown.filter((f) => vals[f.id]).length;
  const check = () => {
    const e: Record<string, string> = {};
    for (const f of shown) {
      const v = (vals[f.id] ?? '').trim();
      if (f.required && !v) e[f.id] = t.requiredError;
      else if (f.type === 'email' && v && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) e[f.id] = t.emailError;
    }
    return e;
  };
  const submit = async (ev: React.FormEvent) => {
    ev.preventDefault();
    const e = check();
    setErrors(e);
    const first = Object.keys(e)[0];
    if (first) return document.getElementById(`${id}-p-${first}`)?.focus();
    setState('busy');
    await run(onAction, 'submit', { answers: vals });
    setState('done');
  };
  const form = (
    <form noValidate onSubmit={submit} className={cn('mx-auto w-full', device === 'phone' ? 'max-w-none' : 'max-w-[38rem]')}>
      {state === 'done' ? (
        <div className="kitm-in py-10 text-center">
          <span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground">
            <Icon name="check" size={26} strokeWidth={2.4} className="kitm-pop" />
          </span>
          <h2 className="mt-5 text-[19px] font-semibold tracking-[-0.02em] text-foreground">{t.thanks}</h2>
          <p className="mx-auto mt-2 max-w-[26rem] text-pretty text-[14px] text-muted-foreground">{t.thanksText}</p>
          <Button
            variant="outline"
            size="sm"
            className="mt-6"
            onClick={() => {
              setVals({});
              setErrors({});
              setState('idle');
            }}
          >
            {t.again}
          </Button>
        </div>
      ) : (
        <>
          <h2 className="text-balance text-[22px] font-semibold tracking-[-0.02em] text-foreground">{formTitle}</h2>
          {formIntro && <p className="mt-1.5 text-pretty text-[14px] text-muted-foreground">{formIntro}</p>}
          <div className="mt-6 grid gap-6">
            {shown.map((f) => (
              <div key={f.id} className="kitm-in">
                <FieldView
                  f={f}
                  t={t}
                  idBase={`${id}-p`}
                  value={vals[f.id] ?? ''}
                  error={errors[f.id]}
                  onChange={(v) => {
                    setVals((x) => ({ ...x, [f.id]: v }));
                    if (errors[f.id]) setErrors((x) => ({ ...x, [f.id]: '' }));
                  }}
                />
              </div>
            ))}
          </div>
          <div className="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
            {/* A line that fills as questions get answered */}
            <span className="flex items-center gap-3 text-[12.5px] tabular-nums text-muted-foreground">
              <span aria-hidden className="block h-1.5 w-full overflow-hidden rounded-full bg-track sm:w-32">
                <span className="block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${round(shown.length ? answered / shown.length : 0, 3)})` }} />
              </span>
              <span className="shrink-0">
                {answered} / {shown.length}
              </span>
            </span>
            <Button type="submit" disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : undefined} icon={state === 'busy' ? undefined : 'arrow-right'}>
              {state === 'busy' ? t.sending : t.submit}
            </Button>
          </div>
        </>
      )}
    </form>
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented
          label={t.device}
          value={device}
          onChange={setDevice}
          className="max-md:hidden"
          options={[
            { value: 'desktop', label: <><Icon name="monitor" size={14} />{t.desktop}</> },
            { value: 'phone', label: <><Icon name="phone" size={14} />{t.phone}</> },
          ]}
        />
      </Header>
      <div ref={scope} {...enter} className="mt-6">
        <div className={cn('kitm-up rounded-xl border border-border bg-surface transition-[padding] duration-500', device === 'phone' ? 'md:px-6 md:py-8' : 'p-0')}>
          <div className={cn('mx-auto bg-card transition-[max-width,border-radius] duration-500 ease-[var(--ease-kit)]', device === 'phone' ? 'md:max-w-[24rem] md:rounded-[2rem] md:border-[10px] md:border-bezel md:shadow-[var(--shadow-lg)]' : 'max-w-none rounded-xl')}>
            <div className="p-5 sm:p-8">{form}</div>
          </div>
        </div>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* responses                                                           */
/* ------------------------------------------------------------------ */

function Responses({ t, title, subtitle, fields, responses = [], now, locale, onAction }: P) {
  const [tab, setTab] = useState<'summary' | 'table'>('summary');
  const [q, setQ] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const num = (n: number, d = 0) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: d, minimumFractionDigits: d }).format(n);
  const day = now.slice(0, 10);
  const todayN = responses.filter((r) => r.time.startsWith(day)).length;
  const rating = fields.find((f) => f.type === 'rating');
  const avg = rating ? responses.reduce((a, r) => a + (Number(r.answers[rating.id]) || 0), 0) / Math.max(1, responses.filter((r) => r.answers[rating.id]).length) : 0;
  const complete = responses.filter((r) => fields.filter((f) => f.required).every((f) => r.answers[f.id])).length;
  const kpis = [
    { label: t.total, value: num(responses.length) },
    { label: t.today, value: num(todayN) },
    { label: t.completion, value: `${num(responses.length ? (complete / responses.length) * 100 : 0)} %` },
    ...(rating ? [{ label: t.avgRating, value: `${num(avg, 1)} / 5` }] : []),
  ];
  const shown = responses.filter((r) => !q || Object.values(r.answers).join(' ').toLowerCase().includes(q.toLowerCase()));
  const time = (iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(Date.parse(iso + 'Z')));
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Button size="sm" variant="outline" iconLeft="download" onClick={() => void onAction?.('download', {})}>
          {t.download}
        </Button>
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-6">
        <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--shadow-sm)] lg:grid-cols-4">
          {kpis.map((k, i) => (
            <div key={k.label} className="bg-card p-4 sm:p-5">
              <div className="kitm-up" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
                <dt className="text-[12.5px] text-muted-foreground">{k.label}</dt>
                <dd className="mt-1 text-[24px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">{k.value}</dd>
              </div>
            </div>
          ))}
        </dl>
        <Segmented
          label={t.responses}
          value={tab}
          onChange={setTab}
          className="kitm-up w-full sm:w-auto sm:justify-self-start"
          options={[
            { value: 'summary', label: t.summary },
            { value: 'table', label: t.table },
          ]}
        />
        {tab === 'summary' ? (
          <div key="s" className="kitm-in grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2">
            {fields
              .filter((f) => f.type === 'choice' || f.type === 'checkbox' || f.type === 'rating')
              .map((f, fi) => {
                const opts = f.type === 'rating' ? ['5', '4', '3', '2', '1'] : f.options ?? [];
                const counts = opts.map((o) => responses.filter((r) => (r.answers[f.id] ?? '').split('|').includes(o)).length);
                const max = Math.max(1, ...counts);
                const totalAns = responses.filter((r) => r.answers[f.id]).length || 1;
                return (
                  <Card key={f.id} title={f.label} description={fill(t.answersN, { n: num(responses.filter((r) => r.answers[f.id]).length) })}>
                    <ul className="grid gap-2.5">
                      {opts.map((o, i) => (
                        <li key={o} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)_3rem] items-center gap-3 text-[13px] sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)_3rem]">
                          <span className="flex min-w-0 items-center gap-1 truncate text-foreground">
                            {f.type === 'rating' ? (
                              <>
                                {o}
                                <Icon name="star" size={13} className="text-warning-foreground [&_path]:fill-current" />
                              </>
                            ) : (
                              o
                            )}
                          </span>
                          <span className="h-2.5 overflow-hidden rounded-full bg-track">
                            <span className={cn('kitm-grow-x block h-full rounded-full', counts[i] === max ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_45%,var(--muted))]')} style={{ width: `${round((counts[i] / max) * 100)}%`, '--kitm-d': `${fi * 80 + i * 50}ms` } as React.CSSProperties} />
                          </span>
                          <span className="text-right tabular-nums text-muted-foreground">{num((counts[i] / totalAns) * 100)} %</span>
                        </li>
                      ))}
                    </ul>
                  </Card>
                );
              })}
          </div>
        ) : (
          <section key="t" className="kitm-in min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
            <div className="border-b border-border p-3 sm:p-4">
              <label className="relative block sm:max-w-xs">
                <span className="sr-only">{t.search}</span>
                <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
                <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(control, 'ps-9')} />
              </label>
            </div>
            {/* Tablets and desktop: a table. Phones: one card per response. */}
            <div className="hidden md:block">
              <table className="w-full table-fixed text-left text-[13px]">
                <thead className="border-b border-border bg-surface text-[12px] text-muted-foreground">
                  <tr>
                    <th scope="col" className="w-40 px-4 py-2.5 font-medium">
                      {t.time}
                    </th>
                    {fields.slice(0, 4).map((f) => (
                      <th key={f.id} scope="col" className="truncate px-3 py-2.5 font-medium">
                        {f.label}
                      </th>
                    ))}
                  </tr>
                </thead>
                <tbody className="divide-y divide-border">
                  {shown.map((r) => (
                    <tr key={r.id} className="hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]">
                      <td className="whitespace-nowrap px-4 py-3 tabular-nums text-muted-foreground">{time(r.time)}</td>
                      {fields.slice(0, 4).map((f) => (
                        <td key={f.id} className="truncate px-3 py-3 text-foreground">
                          {(r.answers[f.id] ?? '').replace(/\|/g, ', ') || <span className="text-subtle-foreground">{t.noAnswer}</span>}
                        </td>
                      ))}
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
            <ul className="divide-y divide-border md:hidden">
              {shown.map((r) => (
                <li key={r.id} className="px-4 py-3.5">
                  <p className="text-[12px] tabular-nums text-muted-foreground">{time(r.time)}</p>
                  <dl className="mt-1.5 grid gap-1">
                    {fields.slice(0, 4).map((f) => (
                      <div key={f.id} className="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 text-[13px]">
                        <dt className="truncate text-muted-foreground">{f.label}</dt>
                        <dd className="truncate text-foreground">{(r.answers[f.id] ?? '').replace(/\|/g, ', ') || t.noAnswer}</dd>
                      </div>
                    ))}
                  </dl>
                </li>
              ))}
            </ul>
          </section>
        )}
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* publish                                                             */
/* ------------------------------------------------------------------ */

/** A small seeded random generator (mulberry32, public domain) so the drawn code is the same on server and browser */
function seeded(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let x = a;
    x = Math.imul(x ^ (x >>> 15), x | 1);
    x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
}

/** A drawn code in the look of a QR code, for the demo. Pass a real one in production. */
function CodeArt({ text }: { text: string }) {
  const n = 25;
  const cells = useMemo(() => {
    const rnd = seeded([...text].reduce((a, ch) => (a * 31 + ch.charCodeAt(0)) >>> 0, 7));
    const out: [number, number][] = [];
    const finder = (x: number, y: number) => (x < 8 && y < 8) || (x >= n - 8 && y < 8) || (x < 8 && y >= n - 8);
    for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (!finder(x, y) && rnd() > 0.52) out.push([x, y]);
    return out;
  }, [text]);
  const eye = (x: number, y: number) => (
    <g key={`${x}-${y}`}>
      <rect x={x} y={y} width={7} height={7} rx={1.8} fill="currentColor" />
      <rect x={x + 1} y={y + 1} width={5} height={5} rx={1.2} fill="#fff" />
      <rect x={x + 2} y={y + 2} width={3} height={3} rx={0.8} fill="currentColor" />
    </g>
  );
  return (
    <svg viewBox={`-1 -1 ${n + 2} ${n + 2}`} className="size-full text-neutral-900" aria-hidden>
      <rect x={-1} y={-1} width={n + 2} height={n + 2} rx={2} fill="#fff" />
      {cells.map(([x, y]) => (
        <rect key={`${x}.${y}`} x={x + 0.08} y={y + 0.08} width={0.84} height={0.84} rx={0.25} fill="currentColor" />
      ))}
      {eye(0, 0)}
      {eye(n - 7, 0)}
      {eye(0, n - 7)}
    </svg>
  );
}

function CopyRow({ value, t, multiline }: { value: string; t: Texts; multiline?: boolean }) {
  const [done, setDone] = useState(false);
  useEffect(() => {
    if (!done) return;
    const id = window.setTimeout(() => setDone(false), 1600);
    return () => window.clearTimeout(id);
  }, [done]);
  return (
    <div className={cn('flex min-w-0 gap-1 rounded-lg border border-border bg-surface p-1.5 ps-3.5', multiline ? 'items-start' : 'items-center')}>
      <code className={cn('min-w-0 flex-1 py-1.5 font-[inherit] text-[13px] text-foreground', multiline ? 'whitespace-pre-wrap leading-relaxed [overflow-wrap:anywhere]' : 'truncate')}>{value}</code>
      <button
        type="button"
        onClick={async () => {
          try {
            await navigator.clipboard.writeText(value);
          } catch {
            /* The check still shows */
          }
          setDone(true);
        }}
        className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8"
      >
        <Icon key={String(done)} name={done ? 'check' : 'copy'} size={14} className={done ? 'kitm-tick text-success-foreground' : 'kitm-in [--kitm-t:.25s]'} />
        <span key={`l${done}`} className="kitm-in [--kitm-t:.25s]">
          {done ? t.copied : t.copy}
        </span>
      </button>
    </div>
  );
}

function Publish({ t, title, subtitle, url = 'https://forms.example.com/f/abc123', onAction }: P) {
  const [state, setState] = useState<'draft' | 'busy' | 'live'>('draft');
  const [accept, setAccept] = useState(true);
  const [close, setClose] = useState(false);
  const [limit, setLimit] = useState(false);
  const [notify, setNotify] = useState(true);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const embed = `<iframe src="${url}?embed=1" width="100%" height="640" style="border:0" title="Form"></iframe>`;
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <span className={cn('inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium', state === 'live' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-muted text-muted-foreground')}>
          <span aria-hidden className={cn('size-1.5 rounded-full', state === 'live' ? 'bg-success motion-safe:animate-pulse' : 'bg-subtle-foreground')} />
          {state === 'live' ? t.published : t.draft}
        </span>
      </Header>
      {state === 'live' && (
        <p role="status" className="kitm-in mt-5 flex items-start gap-2.5 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4 text-[13.5px] text-foreground">
          <Icon name="check-circle" size={17} className="mt-px shrink-0 text-success-foreground" />
          {t.live}
        </p>
      )}
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        <div className="grid min-w-0 gap-6">
          <Card title={t.link} className="kitm-up">
            <CopyRow value={url} t={t} />
          </Card>
          <Card title={t.embed} description={t.embedText} className="kitm-up [--kitm-d:80ms]">
            <CopyRow value={embed} t={t} multiline />
          </Card>
          <Card title={t.options_} className="kitm-up [--kitm-d:160ms]" bodyClassName="p-0 sm:p-0">
            <ul className="divide-y divide-border">
              {[
                { label: t.accept, desc: t.acceptText, on: accept, set: setAccept },
                { label: t.closeOn, desc: '31.12.2026', on: close, set: setClose },
                { label: t.limit, desc: fill(t.limitN, { n: 500 }), on: limit, set: setLimit },
                { label: t.notify, on: notify, set: setNotify },
              ].map((x) => (
                <li key={x.label} className="px-4 py-1 sm:px-5">
                  <Switch checked={x.on} onCheckedChange={x.set} label={x.label} description={x.desc} className="py-2.5" />
                </li>
              ))}
            </ul>
          </Card>
        </div>
        <aside className="kitm-up [--kitm-d:120ms] grid gap-6 lg:sticky lg:top-6">
          <Card title={t.qr} description={t.qrText}>
            <div className="mx-auto w-40 overflow-hidden rounded-xl border border-border bg-white p-2.5 shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] hover:rotate-[-2deg] hover:scale-[1.03]">
              <CodeArt text={url} />
            </div>
          </Card>
          <Button
            className="w-full"
            disabled={state !== 'draft' || !accept}
            iconLeft={state === 'busy' ? 'loader' : state === 'live' ? 'check' : 'rocket'}
            onClick={async () => {
              setState('busy');
              await run(onAction, 'publish', { accept, close, limit, notify });
              setState('live');
            }}
          >
            {state === 'busy' ? t.going : state === 'live' ? t.published : t.goLive}
          </Button>
        </aside>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* FormBuilder                                                         */
/* ------------------------------------------------------------------ */

/**
 * Form builder screens: fields to add, order and set up, logic that shows
 * questions for fitting answers, a live preview on desktop and phone,
 * responses with a summary and a table, and publishing with link, embed
 * code and QR code.
 *   <FormBuilder variant="builder" title="Feedback" fields={[{ id, type, label, required, options }]} />
 */
export function FormBuilder({ variant = 'builder', sidebar, labels, className, fields = [], now = '2026-10-19T14:30', ...props }: FormBuilderProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: P = { ...props, t, locale, fields, now };
  const body = (() => {
    switch (variant) {
      case 'logic':
        return <Logic {...p} />;
      case 'preview':
        return <Preview {...p} />;
      case 'responses':
        return <Responses {...p} />;
      case 'publish':
        return <Publish {...p} />;
      default:
        return <Builder {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Häufige Fragen

Wie installiere ich Form Builder?

Mit aiw add @ai-website/form-builder. Der Befehl legt die Datei components/ui/form-builder.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, switch und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Form Builder?

5 Varianten, Builder, Logik, Live-Vorschau, Antworten und Veröffentlichen. Du wählst sie mit der Prop variant, zum Beispiel variant="logic".

Kann ich die Texte von Form Builder übersetzen?

Ja. Form Builder bringt 84 eigene Texte auf Deutsch und Englisch mit, etwa „Kurzer Text“, „E-Mail“ und „Langer Text“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Form Builder einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Form Builder mit aiw_components und installieren sie mit aiw_add @ai-website/form-builder. Nenn im Prompt die Variante, etwa variant="builder", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Form Builder ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden FormBuilder-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert