// MIRROR — this file exists byte-identical in web/src/components/app/forms/ // and forms/src/. Edit both copies together; scripts/check-forms-mirror.sh // (run by `make lint`) fails the build when they drift. It is self-contained // on purpose: the builder canvas and the hosted page must resolve a design // and split pages exactly the same way, and this file is that single truth. export interface DesignInput { font_family?: string; page_background?: string; form_background?: string; text_color?: string; label_color?: string; input_background?: string; input_border_color?: string; input_text_color?: string; placeholder_color?: string; accent_color?: string; button_background?: string; button_text_color?: string; button_text?: string; button_size?: string; button_full_width?: boolean; border_radius?: number; max_width?: number; spacing?: string; shadow?: boolean; theme?: string; layout?: string; mode?: string; page_background_end?: string; align?: string; show_progress?: boolean; /** "cover" (fill), "contain" (fit) or "tile" (repeat). */ background_size?: string; /** 0-100: how much the page color veils the image, for readable text. */ background_overlay?: number; /** A bar carrying the logo and a title. */ header_enabled?: boolean; header_title?: string; header_background?: string; /** "page" spans the viewport, "inline" sits with the form itself. */ header_placement?: string; /** "left", "center" or "between"; defaults to the form's alignment. */ header_align?: string; /** Keep a page-width header visible while the form scrolls. */ header_sticky?: boolean; /** false leaves the logo in its own placement and gives the header the * title alone. Defaults to true. */ header_show_logo?: boolean; /** Text over the Split layout's cover panel. */ cover_title?: string; cover_subtitle?: string; /** "sm" | "md" | "lg": how tall the logo renders. */ logo_size?: string; /** "card" sits it on the form surface, "page" above the card. */ logo_position?: string; } // CoreField is the structural slice of a form field the page math needs; // both trees' FormField types satisfy it. export interface CoreField { id: string; type: string; label: string; } export type FormLayout = "card" | "wide" | "split"; export type FormMode = "classic" | "focus"; export type FormAlign = "left" | "center"; export type FormBackgroundSize = "cover" | "contain" | "tile"; export type HeaderPlacement = "page" | "inline"; export type HeaderAlign = "left" | "center" | "between"; // Keep in sync with the font_family switch in internal/models/form.go. export const FONT_CATALOG: Record = { system: { label: "System", stack: "system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" }, inter: { label: "Inter", stack: "'Inter',system-ui,-apple-system,'Segoe UI',sans-serif", google: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap", }, serif: { label: "Serif", stack: "Georgia,'Times New Roman',serif" }, mono: { label: "Mono", stack: "ui-monospace,SFMono-Regular,Menlo,monospace" }, manrope: { label: "Manrope", stack: "'Manrope',system-ui,sans-serif", google: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap", }, sora: { label: "Sora", stack: "'Sora',system-ui,sans-serif", google: "https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600&display=swap", }, fraunces: { label: "Fraunces", stack: "'Fraunces',Georgia,serif", google: "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&display=swap", }, "space-grotesk": { label: "Space Grotesk", stack: "'Space Grotesk',system-ui,sans-serif", google: "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap", }, }; export interface ResolvedDesign { font: string; fontStack: string; googleFontUrl: string | null; pageBg: string; pageBg2: string | null; /** Solid color, or a vertical gradient when page_background_end is set. */ pageBgCss: string; formBg: string; text: string; label: string; inputBg: string; inputBorder: string; inputText: string; placeholder: string; accent: string; btnBg: string; btnText: string; btnLabel: string; btnPad: string; btnFont: number; btnFullWidth: boolean; radius: number; inputRadius: number; maxWidth: number; gap: number; shadow: boolean; layout: FormLayout; mode: FormMode; align: FormAlign; showProgress: boolean; bgSize: FormBackgroundSize; /** 0..1, already normalized from the 0-100 the editor stores. */ bgOverlay: number; headerEnabled: boolean; headerTitle: string; headerBg: string; headerPlacement: HeaderPlacement; headerAlign: HeaderAlign; headerSticky: boolean; headerShowLogo: boolean; coverTitle: string; coverSubtitle: string; logoHeight: number; /** true renders the logo above the card, on the page background. */ logoOnPage: boolean; } export function resolveDesign(d: DesignInput): ResolvedDesign { const font = d.font_family && FONT_CATALOG[d.font_family] ? d.font_family : "system"; const radius = d.border_radius ?? 10; const btnSizes: Record = { sm: ["8px 16px", 13], md: ["10px 20px", 14], lg: ["13px 26px", 16], }; const [btnPad, btnFont] = btnSizes[d.button_size ?? "md"] ?? btnSizes.md; const pageBg = d.page_background || "#f8fafc"; const pageBg2 = d.page_background_end || null; const layout: FormLayout = d.layout === "wide" || d.layout === "split" ? d.layout : "card"; return { font, fontStack: FONT_CATALOG[font].stack, googleFontUrl: FONT_CATALOG[font].google ?? null, pageBg, pageBg2, pageBgCss: pageBg2 ? `linear-gradient(180deg, ${pageBg}, ${pageBg2})` : pageBg, formBg: d.form_background || "#ffffff", text: d.text_color || "#0f172a", label: d.label_color || "#334155", inputBg: d.input_background || "#ffffff", inputBorder: d.input_border_color || "#e2e8f0", inputText: d.input_text_color || "#0f172a", placeholder: d.placeholder_color || "#94a3b8", accent: d.accent_color || "#0284c7", btnBg: d.button_background || "#0284c7", btnText: d.button_text_color || "#ffffff", btnLabel: d.button_text || "Submit", btnPad, btnFont, btnFullWidth: !!d.button_full_width, radius, inputRadius: Math.min(radius, 12), maxWidth: d.max_width ?? 560, gap: d.spacing === "compact" ? 10 : d.spacing === "relaxed" ? 22 : 16, // Only the card layout carries a box that can cast a shadow. shadow: (d.shadow ?? true) && layout === "card", layout, mode: d.mode === "focus" ? "focus" : "classic", align: d.align === "center" ? "center" : "left", showProgress: d.show_progress ?? true, bgSize: d.background_size === "contain" || d.background_size === "tile" ? d.background_size : "cover", // Clamped here so a bad stored value can never wash the page out. bgOverlay: Math.min(100, Math.max(0, d.background_overlay ?? 0)) / 100, headerEnabled: !!d.header_enabled, headerTitle: d.header_title || "", headerPlacement: d.header_placement === "inline" ? "inline" : "page", // Unset follows the form's own alignment, so a centered form gets a // centered header without a second decision. headerAlign: d.header_align === "left" || d.header_align === "center" || d.header_align === "between" ? d.header_align : d.align === "center" ? "center" : "left", // Sticky only means anything for a page-width bar. headerSticky: !!d.header_sticky && d.header_placement !== "inline", headerShowLogo: d.header_show_logo ?? true, // The header sits on the form surface by default so it reads as chrome // rather than a second background. headerBg: d.header_background || d.form_background || "#ffffff", coverTitle: d.cover_title || "", coverSubtitle: d.cover_subtitle || "", logoHeight: { sm: 24, md: 36, lg: 52 }[d.logo_size ?? "md"] ?? 36, // Only the card layout has a box to sit above; wide and split put the // logo on the form surface either way. logoOnPage: d.logo_position === "page" && (d.layout ?? "card") === "card", }; } // designVars is the full --wf-* variable map form-theme.css reads. The hosted // page paints it onto documentElement, the builder canvas spreads it as an // inline style; the shared name list is what keeps the two from drifting. export function designVars(r: ResolvedDesign): Record { return { "--wf-font": r.fontStack, "--wf-page-bg": r.pageBg, "--wf-page-bg-css": r.pageBgCss, "--wf-form-bg": r.formBg, "--wf-text": r.text, "--wf-label": r.label, "--wf-input-bg": r.inputBg, "--wf-input-border": r.inputBorder, "--wf-input-text": r.inputText, "--wf-placeholder": r.placeholder, "--wf-accent": r.accent, "--wf-btn-bg": r.btnBg, "--wf-btn-text": r.btnText, "--wf-btn-pad": r.btnPad, "--wf-btn-font": `${r.btnFont}px`, "--wf-radius": `${r.radius}px`, "--wf-input-radius": `${r.inputRadius}px`, "--wf-max-width": `${r.maxWidth}px`, "--wf-gap": `${r.gap}px`, "--wf-shadow": r.shadow ? "0 1px 2px rgba(15,23,42,.06),0 8px 24px rgba(15,23,42,.08)" : "none", "--wf-bg-size": r.bgSize === "tile" ? "auto" : r.bgSize, "--wf-bg-repeat": r.bgSize === "tile" ? "repeat" : "no-repeat", "--wf-bg-overlay": String(r.bgOverlay), "--wf-header-bg": r.headerBg, "--wf-logo-h": `${r.logoHeight}px`, }; } // ensureFont loads the resolved Google font once per document. export function ensureFont(r: ResolvedDesign) { if (!r.googleFontUrl) return; const id = `wf-font-${r.font}`; if (document.getElementById(id)) return; const link = document.createElement("link"); link.id = id; link.rel = "stylesheet"; link.href = r.googleFontUrl; document.head.appendChild(link); } export interface FormPage { title: string; fields: F[]; } const isVisibleInput = (f: CoreField) => !["heading", "paragraph", "divider", "page_break", "hidden"].includes(f.type); // splitPages cuts the flat field list on page_break blocks. The break's label // titles the page it OPENS; pages left with no fields at all are dropped so a // stray leading or trailing break cannot render an empty screen. export function splitPages(fields: F[]): FormPage[] { const pages: FormPage[] = [{ title: "", fields: [] }]; for (const f of fields) { if (f.type === "page_break") { pages.push({ title: f.label ?? "", fields: [] }); } else { pages[pages.length - 1].fields.push(f); } } const filled = pages.filter((p) => p.fields.length > 0); return filled.length > 0 ? filled : [pages[0]]; } // focusSteps groups the list for focus mode: every visible input anchors one // screen, carrying the layout blocks and hidden fields written before it; // trailing blocks join the last screen. Page breaks are boundaries only. export function focusSteps(fields: F[]): F[][] { const steps: F[][] = []; let pending: F[] = []; for (const f of fields) { if (f.type === "page_break") continue; pending.push(f); if (isVisibleInput(f)) { steps.push(pending); pending = []; } } if (pending.length > 0) { if (steps.length > 0) steps[steps.length - 1].push(...pending); else steps.push(pending); } return steps; } // pageIndexOf maps every field id to the classic page it lives on, so focus // mode can report funnel progress in page terms. export function pageIndexOf(fields: CoreField[]): Record { const out: Record = {}; let page = 0; for (const f of fields) { if (f.type === "page_break") { page += 1; } else { out[f.id] = page; } } return out; }