mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 00:03:10 +00:00
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
// 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<string, { label: string; stack: string; google?: string }> = {
|
|
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<string, [string, number]> = {
|
|
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<string, string> {
|
|
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<F extends CoreField> {
|
|
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<F extends CoreField>(fields: F[]): FormPage<F>[] {
|
|
const pages: FormPage<F>[] = [{ 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<F extends CoreField>(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<string, number> {
|
|
const out: Record<string, number> = {};
|
|
let page = 0;
|
|
for (const f of fields) {
|
|
if (f.type === "page_break") {
|
|
page += 1;
|
|
} else {
|
|
out[f.id] = page;
|
|
}
|
|
}
|
|
return out;
|
|
}
|