@formlink/runtime
v0.1.0-alpha.8
Published
Updated: 2025‑10‑31
Readme
@formlink/runtime — Headless Runtime (alpha)
Updated: 2025‑10‑31
Single‑install, headless runtime for building delightful forms with deterministic validation, branching, navigation, persistence and uploads. React UI helpers and Devtools included. The canonical schema is embedded and exported via ./schema — install one package only.
Audience: App developers, LLM codegen, and UI engineers who need an authoritative, single source of truth. This doc is the authoritative reference for v1 alpha.
Table of contents
- Why
- Install
- Exports & Subpaths
- Prerequisites (shadcn/ui provider)
- Host Setup Checklist
- Quickstarts (Typeform, Classic)
- Provider & UI Helpers
- Devtools
- Core API Reference (signatures)
- Transports (fetch, formfiller, mock)
- Schema (./schema) — types & shapes
- Error Visibility Policy
- Branching & Progress
- File Uploads
- Styles
- SSR/Edge
- FAQ
Why
- Single source of truth for validation, visibility/branching, progress, persistence.
- Deterministic execution (code) over AI at runtime; AI is for generation.
- Single install:
@formlink/runtimeexports./schemato prevent drift.
Install
- pnpm add @formlink/runtime react react-dom
- Optional UI helpers: a shadcn/ui component set is required; you can:
- Map your own primitives via ShadCnProvider (recommended), or
- Use our registry to pull UI sources (WIP), or
- Use the future package @formlink/ui when it’s published (WIP — not on npm yet) Notes: ESM‑only; edge‑safe; peer deps react/react‑dom.
Exports & Subpaths
- Core:
@formlink/runtime - UI (React):
@formlink/runtime/ui/react - Devtools:
@formlink/runtime/devtools - Schema:
@formlink/runtime/schema - Runtime CSS (precompiled):
@formlink/runtime/ui/react/style.css - Tailwind preset:
@formlink/runtime/tailwind-preset - Tailwind source sheet (optional):
@formlink/runtime/styles/runtime-tailwind.cssWIP packages @formlink/ui— not yet published; until then, supply your own primitives or pull sources from our registry (see docs/registry/registry.json).@formlink/chat— not yet published; conversational adapters will ship separately.
Prerequisites (shadcn/ui provider)
- Supply shadcn/ui‑style primitives via
ShadCnProvider(or equivalent components). These primitives should include their own styles (from your design system or, later,@formlink/ui). - If you don’t have a design system, import our minimal fallback CSS
@formlink/runtime/ui/react/style.cssto style runtime helpers (layout/progress/navigation) and ensure your primitives are styled.
shadcn/ui mapping (two approaches)
- Map your own primitives via
ShadCnProvider(see Provider & UI Helpers). - Or pull our ejectable sources from the registry (WIP) and map them; later you’ll be able to install
@formlink/uiand map its components directly.
Host Setup Checklist
- [ ]
ShadCnProvideris mounted at app root with required primitives. - [ ] Import
@formlink/runtime/ui/react/style.css(minimal CSS) OR ensure your design‑system CSS is present (or future@formlink/uiglobals). - [ ] If using Typeform helpers, pages/components rendering them are client components ('use client').
Quickstart — Typeform style (reactive)
"use client";
import React from "react";
import { createRuntime } from "@formlink/runtime";
import {
TypeFormTextInput,
TypeFormContinueFooter,
RuntimeProvider,
ShadCnProvider,
} from "@formlink/runtime/ui/react";
import type { Form } from "@formlink/runtime/schema";
const form: Form = {
id: "waitlist",
title: "Join",
questions: [
{
id: "email",
title: "Work email",
type: { name: "text", format: "email" },
validations: { required: { value: true } },
},
],
};
export default function View() {
const rt = React.useMemo(
() => createRuntime({ form, uiMode: "typeform" }),
[],
);
const snap = React.useSyncExternalStore(
rt.context.subscribe,
rt.context.getSnapshot,
rt.context.getSnapshot,
);
const qId =
snap.currentId ?? snap.firstUnansweredId ?? snap.eligibleIds[0] ?? null;
const q = qId ? rt.context.get.q(qId) : undefined;
async function onContinue() {
if (!qId) return;
const res = await rt.actions.validate(qId);
if (res.isValid) await rt.actions.next();
}
return (
<RuntimeProvider runtime={rt} showDevtools>
<ShadCnProvider
components={
{
/* your primitives */
} as any
}
>
{snap.status === "idle" ? (
<button onClick={() => rt.actions.start()}>Start</button>
) : q ? (
<div>
<h1>{rt.context.form.title}</h1>
<h2>{q.title}</h2>
<TypeFormTextInput
type={(q.type as any).format}
value={String(rt.context.get.value(qId) ?? "")}
onChange={(v) => rt.actions.set(qId, v)}
/>
<div className="text-red-500 text-sm">
{rt.context.get.visibleError(qId)}
</div>
<TypeFormContinueFooter
onClick={onContinue}
isLoadingNext={snap.isSubmitting}
/>
</div>
) : null}
</ShadCnProvider>
</RuntimeProvider>
);
}Quickstart — Classic (reactive)
"use client";
import React from "react";
import { createRuntime } from "@formlink/runtime";
import type { Form } from "@formlink/runtime/schema";
const form: Form = {
id: "ex",
title: "Example",
questions: [
{ id: "name", title: "Name", type: { name: "text", format: "text" } },
],
};
export default function ClassicPage() {
const rt = React.useMemo(
() => createRuntime({ form, uiMode: "classic" }),
[],
);
const snap = React.useSyncExternalStore(
rt.context.subscribe,
rt.context.getSnapshot,
rt.context.getSnapshot,
);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
await rt.actions.submit();
}}
>
{snap.eligibleIds.map((qid) => (
<div key={qid}>
<label>{rt.context.get.q(qid)?.title}</label>
<input
value={String(rt.context.get.value(qid) ?? "")}
onChange={(e) => rt.actions.set(qid, e.target.value)}
onBlur={() => rt.actions.blur(qid)}
/>
<div className="text-sm text-red-500">
{rt.context.get.visibleError(qid)}
</div>
</div>
))}
<button type="submit">Submit</button>
</form>
);
}Provider & UI Helpers (React)
- ShadCnProvider maps your primitives; required keys at minimum: Button, Input, Textarea, Label, Badge, ScrollArea, Separator, PopoverRoot/Trigger/Content/Anchor, CommandRoot/List/Item/Group/Empty/Input/Separator. See packages/runtime/docs/formlink-runtime-spec_v1_normative_only.md.
- Exported helpers:
- Typeform scaffolding: TypeFormLayout, TypeFormProgress, TypeFormQuestionHeader, TypeFormContinueFooter({ onClick, isLoadingNext? }), TypeFormNavigation({ onNext?, onPrevious?, canGoNext?, canGoPrevious?, isLoadingNext? }), TypeFormTransition.
- Unified inputs: UnifiedDropdownSelect({ mode:'typeform'|'chat', options, value, onChange, onSubmit? … }), UnifiedDropdownMultiSelect({ options, value: T[], onChange, onSubmit? … }), UnifiedCountrySelect, UnifiedDatePicker, UnifiedPhoneInput, UnifiedFileUpload, InlineSelect, InlineMultiSelect, InlineRating, InlineRanking, InlineSignature.
- RuntimeProvider (optional React context + Devtools). There is no public useRuntime export; subscribe via useSyncExternalStore as shown in quickstarts.
- Fallback CSS: import '@formlink/runtime/ui/react/style.css' if your Tailwind pipeline does not scan the runtime.
Examples
- docs/examples/universal-typeform.md
- docs/examples/universal-classic.md
- docs/examples/composed-react-wiring.md
Common Pitfalls
- docs/pitfalls/start-button-no-op.md
- docs/pitfalls/createRuntime-import.md
- docs/pitfalls/mockTransport-alias.md
- docs/pitfalls/useRuntime-export-missing.md
- docs/pitfalls/question-undefined-title.md
Devtools
import { Devtools } from '@formlink/runtime/devtools'
<Devtools runtime={rt} mode="overlay" />
// or docked
<Devtools runtime={rt} mode="dock-left" dockWidth={360} />Props: { runtime: RuntimeApi; position?: 'bottom-left'|'bottom-right'|'bottom-center'; fixed?: boolean; triggerClassName?: string; label?: string; mode?: 'overlay'|'dock-left'; dockWidth?: number }
Core API Reference (signatures)
// createRuntime
declare function createRuntime(config: RuntimeConfig): RuntimeApi;
// Config
interface RuntimeConfig {
form: Form;
transport?: RuntimeTransport;
formfiller?: FormfillerTransportConfig;
initialValues?: Partial<RuntimeValues>;
initialStatus?: RuntimeStatus;
initialCurrentId?: string | null;
uiMode?: "typeform" | "classic";
}
// Context
interface RuntimeContext {
readonly form: Form;
readonly status: RuntimeStatus;
readonly currentId: string | null;
readonly eligibleIds: string[];
readonly progress: { index: number; total: number; percent: number };
readonly values: RuntimeValues;
readonly errors: Record<string, string[]>;
readonly firstUnansweredId: string | null;
readonly unansweredIds: string[];
readonly isValid: boolean;
readonly isSubmitting: boolean;
subscribe(listener: (s: RuntimeContextSnapshot) => void): () => void;
getSnapshot(): RuntimeContextSnapshot;
get: {
q(qId: string): Question | undefined;
value<T = unknown>(qId: string): T | undefined;
error(qId: string): string | undefined;
visibleError(qId: string): string | undefined;
};
}
// Actions
interface RuntimeActions {
start(): void;
set(qId: string, value: unknown): void;
blur(qId: string): void;
next(): Promise<void>;
prev(): void;
goTo(qId: string): void;
validate(qId: string): Promise<RuntimeValidationResult>;
validateAll(): Promise<RuntimeValidationResult>;
submit(): Promise<void>;
reset(): void;
savePartial(): Promise<void>;
upload(qId: string, file: File | Blob): Promise<RuntimeUploadDescriptor>;
}
// Events
type RuntimeEventMap = {
"status:change": { status: RuntimeStatus };
"cursor:change": { currentId: string | null };
"answer:set": { questionId: string; value: unknown };
"visibility:change": { eligibleIds: string[] };
"progress:change": { progress: RuntimeProgress };
"submit:requested": { values: RuntimeValues };
"submit:transport:start": { values: RuntimeValues };
"submit:success": { values: RuntimeValues; result: RuntimeSubmissionResult };
"submit:error": { error: unknown };
"submit:transport:end": { result: RuntimeSubmissionResult | unknown };
"upload:success": { questionId: string; descriptor: RuntimeUploadDescriptor };
"upload:error": { questionId: string; error: unknown };
};Transports
// Generic fetch transport
class RuntimeTransportError extends Error {
constructor(message: string, status?: number);
}
interface FetchTransportOptions {
baseUrl: string;
submitPath?: string; // default: /forms/submit
partialPath?: string; // default: /forms/save
uploadPath?: string; // default: /files/upload
headers?: Record<string, string>;
}
declare function fetchTransport(
options: FetchTransportOptions,
): RuntimeTransport;
// Formfiller‑compatible
type FormfillerTransportOptions = {
baseUrl?: string;
formId: string;
submissionId: string;
formVersionId: string;
isTestSubmission?: boolean;
headers?: Record<string, string>;
};
declare function createFormfillerTransport(
opts: FormfillerTransportOptions,
): RuntimeTransport;
// Mock (dev)
type MockTransportOptions = {
delayMs?: number;
saveDelayMs?: number;
uploadDelayMs?: number;
onSubmit?: (values: RuntimeValues) => Promise<unknown> | unknown;
onSavePartial?: (values: RuntimeValues) => Promise<void> | void;
onUpload?: (
qId: string,
file: File | Blob,
) => Promise<RuntimeUploadDescriptor> | RuntimeUploadDescriptor;
generateObjectUrl?: boolean;
revokeAfterMs?: number;
};
declare function createMockTransport(
options?: MockTransportOptions,
): RuntimeTransport;Schema (single‑install)
- Import types and Zod schemas from
@formlink/runtime/schema. - Discriminated union by
type.name; validations include required/minLength/maxLength/pattern/minSelections/maxSelections/maxFiles/minDate/maxDate/allowedTypes/maxSize. - The runtime constructs a Zod object from your Form for on‑change and on‑submit validation.
Error Visibility Policy
- typeform mode: reveal on Continue (next) if invalid; reveal all on submit; clear on valid change.
- classic mode: reveal on blur; clear on valid change.
- Use
visibleError(qId)for rendering messages; do not compute visibility in the UI.
Branching & Progress
eligibleIdsis the authoritative ordered list of visible questions; navigation respects it.progressderives fromeligibleIdsandcurrentId.
File Uploads
- Normalized descriptor:
{ url, name, size, mimeType?, metadata? }. - Use
actions.upload(qId, file)or providetransport.upload. UnifiedFileUploadintegrates with the runtime (passquestionIdandonFileUpload).
Styles
You have two options:
Drop-in CSS bundle
Import the prebuilt file once in your host app (e.g._app.tsx,layout.tsx, or entry CSS):import "@formlink/runtime/ui/react/style.css";This includes the runtime layout utilities, tokens, and Tailwind utility classes used by our helpers.
Integrate with your own Tailwind pipeline
Extend your Tailwind config with the packaged preset and keep your existing design language:// tailwind.config.cjs const runtimePreset = require("@formlink/runtime/tailwind-preset"); module.exports = { presets: [runtimePreset], content: [ "./src/**/*.{ts,tsx}", // ...your content globs ], safelist: [ ...(runtimePreset.safelist || []), // ...your extra safelist entries ], };Then include the source layer in your global stylesheet if you want to reuse our tokens:
/* app.css */ @import "@formlink/runtime/styles/runtime-tailwind.css";The safelist is already bundled in the preset, but you can also
require("@formlink/runtime/tailwind-safelist")and merge manually if you prefer.
During development, run pnpm --filter @formlink/runtime dev:css to watch and rebuild the CSS bundle when editing runtime components locally.
SSR/Edge
- Runtime is ESM‑only and uses browser‑safe APIs. React helpers are client‑side (
'use client').
FAQ
- Can I use it without React? Yes. The headless runtime is framework‑agnostic.
- Does it support chat? Chat adapters will live in
@formlink/chat(WIP); this package focuses on non‑chat flows. - Can I customize UI? Yes. Provide primitives via
ShadCnProvider. A registry and@formlink/uipackage (WIP) will make this turnkey.
License
MIT © Formlink
