@the-portland-company/vrm-builder
v0.8.2
Published
Shared form/survey builder surfaces (canvas, field library, public renderer, results charts, skeletons) for Politogy VRM apps. Framework-agnostic: styling via brand CSS vars, data access via injected adapters.
Readme
@the-portland-company/vrm-builder
Shared builder surfaces for Politogy VRM apps — the form/survey canvas, field library, public renderer, aggregate results charts, and skeletons — extracted from the Forms and Surveys apps so every product renders the same builder from one source (Surveys spec §4, Reference/UI-Platform).
The package is framework-agnostic: it never imports next/*, @supabase/*, or app code. All styling is driven by the shared brand CSS variables (the same --color-* tokens the shell and specs.politogyvrm.com/api/tokens define), and all data access happens through injected adapters.
Install
npm i @the-portland-company/vrm-builderPeer deps (both apps already have them): react/react-dom ^19, @dnd-kit/core/sortable/utilities.
1. Load the primitive CSS
// app/layout.tsx (or globals)
import "@the-portland-company/vrm-builder/css";This ships the .card / .input / .textarea / .select / .btn / .btn-primary / .btn-accent / .pill / .skeleton / .label / .muted primitives on brand vars. It expects the app to also load the runtime tokens <link> (as it already does) so overrides stay live.
2. Add the package to Tailwind's content sources
The components also use Tailwind utility classes, which your app's Tailwind build must compile. In Tailwind v4, add the @source directive to your globals.css:
@import "tailwindcss";
@source "../node_modules/@the-portland-company/vrm-builder/dist";Without this, package components lose their utility styling.
Exports
| Subpath | Contents | Boundary |
| --- | --- | --- |
| . | field-type catalog, validateFieldValue + validation helpers, all types + adapter interfaces | server-safe — pure logic only, no "use client", no @dnd-kit. Import these in server actions / submit routes. |
| ./builder | Canvas, CanvasRow, LibraryPanel, CustomFieldDialog, Frame, Notice, applyReorder | ships "use client" — pulls in @dnd-kit. Import from a client component. Never import these from . |
| ./renderer | PublicFormRenderer, FieldInput, RendererConfig, RendererSubmitAdapter | ships "use client" — safe to render directly from a Server Component page |
| ./charts | computeResults, computeQuestionResult, resultsAreNoisy, QuestionPanel, DEFAULT_PALETTE | server-renderable (pure + SVG) |
| ./skeletons | Skeleton, SkeletonBar | server-renderable |
| ./css | primitive stylesheet | side-effect import |
Adapter contracts
The package renders UI and delegates every mutation/side-effect to adapters you implement with your app's server actions / API routes. Every adapter method returns an ActionResult<T> = { ok, error?, code?, data? }; the code lets the builder react to typed failures (e.g. Forms' "TCPA_REQUIRED") without string-matching.
interface CanvasAdapter {
addField(fieldId: string, extra?: Record<string, unknown>): Promise<ActionResult>;
removeField(canvasFieldId: string): Promise<ActionResult>;
reorder(orderedCanvasFieldIds: string[]): Promise<ActionResult>;
updateField(canvasFieldId: string, patch: { required?: boolean; caption?: string }): Promise<ActionResult>;
}
interface FieldLibraryAdapter {
checkDuplicate?(label, internalName): Promise<ActionResult<{ duplicate?; reason? } | null>>; // omit → no dup step
createCustomField(spec: CustomFieldSpec): Promise<ActionResult<{ id: string } | void>>; // spec.extra passthrough (TCPA)
addExisting?(fieldId, extra?): Promise<ActionResult>; // "use existing field"
}
interface RendererSubmitAdapter {
submit(payload: { values; honeypot; consent; meta? }): Promise<ActionResult<{ confirmation?; redirect?; fieldErrors?; message? }>>;
mapErrorCode?(code: string): string;
}Product differences are expressed through BuilderConfig as predicates and slots, not flags:
type BuilderConfig = {
fieldRestriction?: (f: BuilderLibraryField) => boolean; // Surveys Anonymous blocks identity fields
blockedReason?: string;
onAddIntercept?: (code, retry, ctx) => boolean; // Forms TCPA modal; return true + call retry(extra)
fieldBadges?: (f) => ReactNode; // e.g. "identity"
instrumentLocked?: boolean; // Surveys: live with responses
};App-specific frames (Forms' Publish, Surveys' Deployment Plan) stay app-owned — compose them around the shared Canvas / LibraryPanel, or pass them as FrameSlot[].
Usage
Forms / Surveys canvas (inside your "use client" Builder)
"use client";
import { Canvas, LibraryPanel } from "@the-portland-company/vrm-builder/builder";
<LibraryPanel
library={library} // BuilderLibraryField[]
onCanvas={new Set(fields.map(f => f.field_id))}
canvasAdapter={canvasAdapter}
fieldLibraryAdapter={fieldLibraryAdapter}
config={config}
anonymous={survey.identity_mode === "anonymous"}
onAfterMutation={router.refresh}
/>
<Canvas
fields={fields} // BuilderCanvasField[]
adapter={canvasAdapter}
config={config}
onAfterMutation={router.refresh}
readOnly={readOnly}
/>Public renderer (from a Server Component page)
import { PublicFormRenderer } from "@the-portland-company/vrm-builder/renderer";
<PublicFormRenderer
fields={rendererFields}
config={{ accent, consentLine, privacyPolicyUrl, honeypotEnabled, meta: { token, channel } }}
adapter={{ submit: async (p) => postToApi(p), mapErrorCode }}
/>meta is merged into the submit payload (Surveys token/channel ride here); session_id and source_url are added automatically.
Results charts (server-rendered)
import { computeResults, QuestionPanel } from "@the-portland-company/vrm-builder/charts";
const results = computeResults(fieldSpecs, responseRows /*, lowN */);
{results.map((q) => <QuestionPanel key={q.internal_name} q={q} />)}Server-side validation
import { validateFieldValue } from "@the-portland-company/vrm-builder"; // server-safe
const r = validateFieldValue(field, required, rawValue);
if (!r.ok) return { ok: false, error: r.error };Development
npm run type-check # tsc --noEmit
npm test # vitest run (jsdom)
npm run build # tsup → dist (dual ESM/CJS + d.ts) + ensure "use client" on rendererPublishing runs prepublishOnly = type-check + npm audit --audit-level=high + tests + build.
Install into consumers via
npm packtarballs, nevernpm link— linking double-loads React under opennextjs.
