diff --git a/.gitignore b/.gitignore
index c8fd9e6..acb72cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ test-results/
playwright-report/
.DS_Store
/PLAN.md
+.bench/
diff --git a/apps/demo/apply.html b/apps/demo/apply.html
new file mode 100644
index 0000000..c0800a4
--- /dev/null
+++ b/apps/demo/apply.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+ Apply · Foldworks
+
+
+
+
+
+
diff --git a/apps/demo/bench/mobile-form.mjs b/apps/demo/bench/mobile-form.mjs
new file mode 100644
index 0000000..f3dd8c4
--- /dev/null
+++ b/apps/demo/bench/mobile-form.mjs
@@ -0,0 +1,281 @@
+#!/usr/bin/env node
+/**
+ * Benchmarks the mobile candidate form on an emulated mid-range phone.
+ *
+ * node bench/mobile-form.mjs [--runs 5] [--cpu 4] label=path/to/entry.html [label=http://…]
+ *
+ * Each target must implement the form described in `src/mobile-form` with the
+ * same element ids and button text, so one scripted session drives any
+ * implementation. Files are served from their directory with Brotli, the page
+ * loads over emulated Slow 4G, and the whole session runs with the CPU slowed
+ * by `--cpu` (Lighthouse's mobile default is 4x).
+ *
+ * The session covers every step: text, masks, the state sheet, choice controls,
+ * conditional follow-up questions that animate open, and the one-time code.
+ *
+ * Reported per target (median across runs):
+ * - transfer: compressed bytes for the first load
+ * - FCP and form-ready: first paint, and the moment `#first-name` exists
+ * - key → paint: keydown to the next painted frame, for every keystroke
+ * - tap → paint: click to the next painted frame, for every tap
+ * - INP: the slowest interaction (Event Timing, grouped by interactionId), and how
+ * many interactions took 50ms or more
+ * - script: main-thread JavaScript time for the entire scripted session
+ */
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import { readFile, stat } from "node:fs/promises";
+import { brotliCompressSync, constants } from "node:zlib";
+import { dirname, extname, join, resolve, basename } from "node:path";
+import { chromium, devices } from "playwright";
+
+const args = process.argv.slice(2);
+const option = (name, fallback) => {
+ const index = args.indexOf(`--${name}`);
+ return index === -1 ? fallback : Number(args.splice(index, 2)[1]);
+};
+const runs = option("runs", 5);
+const cpu = option("cpu", 4);
+const targets = args.map((arg) => {
+ const at = arg.indexOf("=");
+ if (at === -1) throw new Error(`Expected label=path-or-url, got ${arg}`);
+ return { label: arg.slice(0, at), location: arg.slice(at + 1) };
+});
+if (targets.length === 0) {
+ console.error("Usage: node bench/mobile-form.mjs [--runs 5] [--cpu 4] label=entry.html …");
+ process.exit(1);
+}
+
+const types = {
+ ".html": "text/html",
+ ".js": "text/javascript",
+ ".css": "text/css",
+ ".svg": "image/svg+xml",
+};
+
+/** Serves a build directory with Brotli, like a CDN would. */
+const serve = async (entry) => {
+ const root = dirname(resolve(entry));
+ const cache = new Map();
+ const server = createServer(async (request, response) => {
+ const path = new URL(request.url ?? "/", "http://x").pathname;
+ const file = join(root, path === "/" ? basename(entry) : path);
+ try {
+ if (!(await stat(file)).isFile()) throw new Error("not a file");
+ let body = cache.get(file);
+ if (body === undefined) {
+ body = brotliCompressSync(await readFile(file), {
+ params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
+ });
+ cache.set(file, body);
+ }
+ response.writeHead(200, {
+ "content-type": types[extname(file)] ?? "application/octet-stream",
+ "content-encoding": "br",
+ "cache-control": "no-store",
+ });
+ response.end(body);
+ } catch {
+ response.writeHead(404).end();
+ }
+ });
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
+ const { port } = server.address();
+ return { url: `http://127.0.0.1:${port}/${basename(entry)}`, close: () => server.close() };
+};
+
+/** Framework-neutral probes, installed before any application code runs. */
+const probe = () => {
+ const bench = { keys: [], taps: [], interactions: {}, ready: 0 };
+ window.__bench = bench;
+ const toPaint = (list) => (event) => {
+ const start = event.timeStamp;
+ requestAnimationFrame(() => setTimeout(() => list.push(performance.now() - start), 0));
+ };
+ addEventListener("keydown", toPaint(bench.keys), true);
+ addEventListener("click", toPaint(bench.taps), true);
+ new MutationObserver((_, observer) => {
+ if (document.getElementById("first-name") !== null) {
+ bench.ready = performance.now();
+ observer.disconnect();
+ }
+ }).observe(document, { childList: true, subtree: true });
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries())
+ if (entry.interactionId > 0)
+ bench.interactions[entry.interactionId] = Math.max(
+ bench.interactions[entry.interactionId] ?? 0,
+ entry.duration,
+ );
+ }).observe({ type: "event", buffered: true, durationThreshold: 16 });
+};
+
+const median = (values) => {
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted.length === 0 ? 0 : sorted[Math.floor(sorted.length / 2)];
+};
+const percentile = (values, p) => {
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted.length === 0
+ ? 0
+ : sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)];
+};
+
+const typing = { delay: 60 };
+
+/** One scripted application: every step, the state sheet, and the code. */
+const session = async (page) => {
+ // Center the field first, as a person scrolls to a field before tapping it.
+ const type = async (selector, text) => {
+ await page.locator(selector).evaluate((element) => element.scrollIntoView({ block: "center" }));
+ await page.tap(selector);
+ await page.locator(selector).pressSequentially(text, typing);
+ };
+ await type("#first-name", "Ada");
+ await type("#last-name", "Lovelace");
+ await type("#email", "ada@example.com");
+ await type("#phone", "5551234567");
+ await type("#dob", "07041990");
+ await page.tap("text=Continue");
+ await page.waitForSelector("#street");
+ await type("#street", "1 Main Street");
+ await type("#city", "Oakland");
+ await type("#zip", "94612");
+ await page.tap("#state");
+ await page.waitForSelector("#state-search");
+ await page.locator("#state-search").pressSequentially("cal", typing);
+ await page.tap('dialog[open] button:has-text("California")');
+ await page.waitForSelector("dialog[open]", { state: "detached", timeout: 2000 }).catch(() => {});
+ await page.tap("text=Continue");
+ await page.waitForSelector("#hours");
+ await page.tap("label:has(#employment-part-time)");
+ for (let index = 0; index < 5; index++) {
+ await page.tap('[aria-label="More hours"]');
+ await page.waitForTimeout(80);
+ }
+ await page.fill("#start-date", "2031-01-15");
+ await page.tap("label:has(#shift-evenings)");
+ await page.tap("label:has(#shift-weekends)");
+ await page.tap("text=Continue");
+ // Conditional questions: each "Yes" animates follow-up fields open.
+ await page.waitForSelector("#license-yes", { state: "attached" });
+ await page.tap("label:has(#license-yes)");
+ await type("#license-number", "D1234567");
+ await page.tap("label:has(#cdl-yes)");
+ await page.tap("label:has(#cdl-class-b)");
+ await page.tap("label:has(#rehire-yes)");
+ await type("#previous-location", "Oakland");
+ await type("#previous-end", "062022");
+ await page.tap("text=Continue");
+ await page.waitForSelector("#otp");
+ await page.locator("#otp").pressSequentially("123456", typing);
+ // Implementations may submit a complete code themselves; otherwise press Verify.
+ if (!(await page.waitForSelector("text=You're all set", { timeout: 500 }).catch(() => null)))
+ await page.tap("button[type=submit]");
+ await page.waitForSelector("text=You're all set");
+};
+
+const metric = (metrics, name) => metrics.find((entry) => entry.name === name)?.value ?? 0;
+
+const runOnce = async (browser, url) => {
+ const context = await browser.newContext({ ...devices["Pixel 7"] });
+ await context.addInitScript(probe);
+ const page = await context.newPage();
+ const cdp = await context.newCDPSession(page);
+ await cdp.send("Network.enable");
+ await cdp.send("Performance.enable");
+ await cdp.send("Network.emulateNetworkConditions", {
+ offline: false,
+ latency: 150,
+ downloadThroughput: (1.6 * 1024 * 1024) / 8,
+ uploadThroughput: (750 * 1024) / 8,
+ });
+ await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpu });
+ let transfer = 0;
+ cdp.on("Network.loadingFinished", (event) => {
+ transfer += event.encodedDataLength;
+ });
+ await page.goto(url, { waitUntil: "load" });
+ await page.waitForSelector("#first-name");
+ await page.waitForFunction(
+ () => performance.getEntriesByName("first-contentful-paint").length > 0,
+ );
+ const load = await page.evaluate(() => ({
+ fcp: performance.getEntriesByName("first-contentful-paint")[0].startTime,
+ ready: window.__bench.ready,
+ }));
+ await cdp.send("Network.emulateNetworkConditions", {
+ offline: false,
+ latency: 0,
+ downloadThroughput: -1,
+ uploadThroughput: -1,
+ });
+ const before = (await cdp.send("Performance.getMetrics")).metrics;
+ await session(page).catch(async (error) => {
+ await page.screenshot({ path: join(tmpdir(), "mobile-form-bench-failure.png") });
+ throw error;
+ });
+ await page.waitForTimeout(300);
+ const after = (await cdp.send("Performance.getMetrics")).metrics;
+ const probes = await page.evaluate(() => window.__bench);
+ await context.close();
+ return {
+ transferKB: transfer / 1024,
+ fcp: load.fcp,
+ ready: load.ready,
+ keys: probes.keys,
+ taps: probes.taps,
+ inp: Math.max(0, ...Object.values(probes.interactions)),
+ slow: Object.values(probes.interactions).filter((duration) => duration >= 50).length,
+ scriptMs: (metric(after, "ScriptDuration") - metric(before, "ScriptDuration")) * 1000,
+ heapMB: metric(after, "JSHeapUsedSize") / 1024 / 1024,
+ };
+};
+
+const browser = await chromium.launch({ channel: process.env.BENCH_CHANNEL ?? "chrome" });
+const rows = [];
+for (const target of targets) {
+ const server = /^https?:/.test(target.location) ? undefined : await serve(target.location);
+ const url = server?.url ?? target.location;
+ const results = [];
+ for (let run = 0; run < runs; run++) {
+ results.push(await runOnce(browser, url));
+ process.stderr.write(`${target.label}: run ${run + 1}/${runs}\n`);
+ }
+ server?.close();
+ const keys = results.flatMap((result) => result.keys);
+ const taps = results.flatMap((result) => result.taps);
+ rows.push({
+ label: target.label,
+ transferKB: median(results.map((result) => result.transferKB)),
+ fcp: median(results.map((result) => result.fcp)),
+ ready: median(results.map((result) => result.ready)),
+ keyP50: percentile(keys, 50),
+ keyP95: percentile(keys, 95),
+ keyMax: Math.max(...keys),
+ tapP50: percentile(taps, 50),
+ tapP95: percentile(taps, 95),
+ inp: median(results.map((result) => result.inp)),
+ slow: median(results.map((result) => result.slow)),
+ scriptMs: median(results.map((result) => result.scriptMs)),
+ heapMB: median(results.map((result) => result.heapMB)),
+ samples: `${keys.length} keys, ${taps.length} taps`,
+ });
+}
+await browser.close();
+
+const fixed = (value, digits = 0) => value.toFixed(digits);
+console.log(
+ `\nPixel 7 emulation, ${cpu}x CPU slowdown, Slow 4G for the first load, ${runs} runs each.\n`,
+);
+console.log(
+ "| Target | Transfer | FCP | Form ready | Key → paint p50 / p95 / max | Tap → paint p50 / p95 | INP | Interactions ≥ 50ms | Script time | JS heap |",
+);
+console.log("|---|---|---|---|---|---|---|---|---|---|");
+for (const row of rows)
+ console.log(
+ `| ${row.label} | ${fixed(row.transferKB, 1)} KB | ${fixed(row.fcp)} ms | ${fixed(row.ready)} ms | ${fixed(row.keyP50, 1)} / ${fixed(row.keyP95, 1)} / ${fixed(row.keyMax, 1)} ms | ${fixed(row.tapP50, 1)} / ${fixed(row.tapP95, 1)} ms | ${fixed(row.inp)} ms | ${row.slow} | ${fixed(row.scriptMs)} ms | ${fixed(row.heapMB, 1)} MB |`,
+ );
+console.log(
+ `\nSamples per target: ${rows.map((row) => `${row.label}: ${row.samples}`).join("; ")}`,
+);
diff --git a/apps/demo/e2e/mobile-form.scenarios.ts b/apps/demo/e2e/mobile-form.scenarios.ts
new file mode 100644
index 0000000..ec41c36
--- /dev/null
+++ b/apps/demo/e2e/mobile-form.scenarios.ts
@@ -0,0 +1,109 @@
+import type { Page } from "playwright";
+import { describe, expect, it } from "vitest";
+
+export const mobileFormScenarios = (getPage: () => Page, appUrl: string) => {
+ describe("mobile inputs", () => {
+ it("guides a phone through the candidate form with masks, the state sheet, and a code", async () => {
+ const page = getPage();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto(`${appUrl}/mobile-form`, { waitUntil: "networkidle" });
+ await page.evaluate(() => localStorage.removeItem("foldworks-mobile-form"));
+ await page.reload({ waitUntil: "networkidle" });
+ const field = (name: string) => page.getByRole("textbox", { name, exact: true });
+
+ await page.getByRole("button", { name: "Continue" }).click();
+ await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("first-name");
+ await expect.poll(() => page.getByText("Enter your first name.").isVisible()).toBe(true);
+
+ await field("First name").pressSequentially("Ada");
+ await field("First name").press("Enter");
+ await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("last-name");
+ await page.keyboard.type("Lovelace");
+ await field("Email").fill("ada@gmial.com");
+ await page.getByRole("button", { name: /Did you mean/ }).click();
+ await expect.poll(() => field("Email").inputValue()).toBe("ada@gmail.com");
+ await field("Mobile phone").pressSequentially("5551234567");
+ await expect.poll(() => field("Mobile phone").inputValue()).toBe("(555) 123-4567");
+ // Typing in the middle keeps the caret after the inserted digit.
+ await field("Mobile phone").evaluate((input: HTMLInputElement) =>
+ input.setSelectionRange(8, 8),
+ );
+ await page.keyboard.type("9");
+ expect(await field("Mobile phone").inputValue()).toBe("(555) 129-3456");
+ expect(
+ await field("Mobile phone").evaluate((input: HTMLInputElement) => input.selectionStart),
+ ).toBe(9);
+ await field("Mobile phone").fill("+1 (555) 123-4567");
+ await expect.poll(() => field("Mobile phone").inputValue()).toBe("(555) 123-4567");
+ await field("Date of birth").pressSequentially("07041990");
+ await expect.poll(() => field("Date of birth").inputValue()).toBe("07/04/1990");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ await page.getByRole("heading", { name: "Where do you live?" }).waitFor();
+ await field("Street address").fill("1 Main Street");
+ await field("City").fill("Oakland");
+ await field("ZIP code").pressSequentially("94612-1234");
+ await expect.poll(() => field("ZIP code").inputValue()).toBe("94612");
+ await page.locator("#state").click();
+ const sheet = page.getByRole("dialog", { name: "State" });
+ await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("state-search");
+ await page.keyboard.type("new y");
+ await expect.poll(() => sheet.getByRole("button").allTextContents()).toEqual(["New YorkNY"]);
+ await page.keyboard.press("Enter");
+ await expect.poll(() => sheet.isVisible()).toBe(false);
+ await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("state");
+ expect(await page.locator("#state").innerText()).toContain("New York");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ await page.getByRole("heading", { name: "Your availability" }).waitFor();
+ await page.getByText("Part-time", { exact: true }).click();
+ for (let index = 0; index < 3; index++)
+ await page.getByRole("button", { name: "More hours" }).click();
+ await expect.poll(() => page.locator("#hours").inputValue()).toBe("23");
+ await page.locator("#start-date").fill("2099-01-15");
+ await page.getByText("Evenings", { exact: true }).click();
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ await page.getByRole("heading", { name: "A few more questions" }).waitFor();
+ const licenseFollowUp = page.locator("#license-follow-up");
+ expect(await licenseFollowUp.getAttribute("inert")).not.toBeNull();
+ await page.getByRole("button", { name: "Continue" }).click();
+ await expect.poll(() => page.getByText("Choose yes or no.").count()).toBe(2);
+ await page.locator("label:has(#license-yes)").click();
+ await expect.poll(() => licenseFollowUp.getAttribute("inert")).toBeNull();
+ await field("License number").fill("D1234567");
+ await page.locator("label:has(#cdl-yes)").click();
+ await page.getByRole("button", { name: "Continue" }).click();
+ // The nested class is now required; answering "No" above hides and releases it.
+ await expect.poll(() => page.getByText("Choose a CDL class.").isVisible()).toBe(true);
+ await page.locator("label:has(#license-no)").click();
+ await expect.poll(() => licenseFollowUp.getAttribute("inert")).not.toBeNull();
+ await expect
+ .poll(() => licenseFollowUp.evaluate((element) => element.getBoundingClientRect().height))
+ .toBe(0);
+ await page.locator("label:has(#license-yes)").click();
+ await expect.poll(() => field("License number").inputValue()).toBe("D1234567");
+ await page.locator("label:has(#license-no)").click();
+ await page.locator("label:has(#rehire-yes)").click();
+ await field("Which location?").fill("Oakland");
+ await field("When did you leave?").pressSequentially("062022");
+ await expect.poll(() => field("When did you leave?").inputValue()).toBe("06/2022");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ await page.getByRole("heading", { name: "Verify your phone" }).waitFor();
+ await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("otp");
+ await page.keyboard.type("12a3456");
+ await page.getByRole("heading", { name: "You're all set" }).waitFor();
+ expect(
+ await page.evaluate(
+ () => JSON.parse(localStorage.getItem("foldworks-mobile-form")!).values.state,
+ ),
+ ).toBe("NY");
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
+ ),
+ ).toBe(true);
+ });
+ });
+};
diff --git a/apps/demo/e2e/workflow.e2e.test.ts b/apps/demo/e2e/workflow.e2e.test.ts
index 3152a55..99fa9fd 100644
--- a/apps/demo/e2e/workflow.e2e.test.ts
+++ b/apps/demo/e2e/workflow.e2e.test.ts
@@ -1,5 +1,6 @@
import { documentAnalysisScenarios } from "./document-analysis.scenarios";
import { themeBuilderScenarios } from "./theme-builder.scenarios";
+import { mobileFormScenarios } from "./mobile-form.scenarios";
import { docsScenarios } from "./docs.scenarios";
import { desktopScenarios } from "./desktop.scenarios";
import { mkdir, readFile } from "node:fs/promises";
@@ -160,6 +161,7 @@ describe("structured workflow builder", { concurrent: false }, () => {
"Text intelligence",
"PDF rendering",
"UI system",
+ "Mobile inputs",
"Theme builder",
]);
await expect.poll(() => sidebar.getByText("Foldworks", { exact: true }).count()).toBe(1);
@@ -1639,6 +1641,7 @@ describe("structured workflow builder", { concurrent: false }, () => {
}, 60_000);
themeBuilderScenarios(() => page, appUrl);
+ mobileFormScenarios(() => page, appUrl);
workbenchScenarios(() => page, appUrl, screenshot);
dataGridEditingScenarios(() => page, appUrl, screenshot);
dataGridCoverageScenarios(() => page, appUrl, screenshot);
diff --git a/apps/demo/package.json b/apps/demo/package.json
index e0075ec..0589c53 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -5,8 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "vite build && vite build --config vite.mcp-app.config.ts",
+ "build": "vite build && vite build --config vite.mcp-app.config.ts && vite build --config vite.mobile-form.config.ts",
"preview": "vite preview",
+ "bench:mobile-form": "vite build --config vite.mobile-form.config.ts --outDir .bench/foldkit && node bench/mobile-form.mjs foldkit=.bench/foldkit/apply.html",
"typecheck": "tsc --noEmit",
"test": "vitest run src",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
diff --git a/apps/demo/src/app/message.ts b/apps/demo/src/app/message.ts
index 8d92007..f77853e 100644
--- a/apps/demo/src/app/message.ts
+++ b/apps/demo/src/app/message.ts
@@ -24,6 +24,7 @@ import { ThemeName, ThemePreference } from "../theme";
import { Message as StatechartMessage } from "../statechart/message";
import { Message as ThemeBuilderMessage } from "../theme-builder/message";
import { Message as UiKitMessage } from "../ui-kit/message";
+import { Message as MobileFormMessage } from "../mobile-form/message";
import { Message as WorkflowEditorMessage } from "../workflow/message";
export const Message = defineMessageUnion({
@@ -55,6 +56,7 @@ export const Message = defineMessageUnion({
GotEditorMessage: { message: EditorMessage },
GotSidebarMessage: { message: Sidebar.Message },
GotUiKitMessage: { message: UiKitMessage },
+ GotMobileFormMessage: { message: MobileFormMessage },
GotThemeBuilderMessage: { message: ThemeBuilderMessage },
GotGenerativeUiAction: { intent: ActionIntent },
});
diff --git a/apps/demo/src/app/model.ts b/apps/demo/src/app/model.ts
index 8bd48f4..2a08aa9 100644
--- a/apps/demo/src/app/model.ts
+++ b/apps/demo/src/app/model.ts
@@ -39,6 +39,7 @@ import {
initialModel as initialThemeBuilder,
} from "../theme-builder/model";
import { Model as UiKitModel, initialModel as initialUiKit } from "../ui-kit/model";
+import { Model as MobileFormModel, init as initMobileForm } from "../mobile-form/model";
import { Model as StatechartModel, initialModel as initialStatechart } from "../statechart/model";
import { Model as WorkflowEditorModel, init as initWorkflowEditor } from "../workflow/model";
import {
@@ -70,6 +71,7 @@ export const Model = S.Struct({
editor: ArticleEditor.Model,
sidebar: Sidebar.Model,
uiKit: UiKitModel,
+ mobileForm: MobileFormModel,
themeBuilder: ThemeBuilderModel,
themeName: ThemeName,
themePreference: ThemePreference,
@@ -115,6 +117,7 @@ export const init = (
editor: initialEditor(),
sidebar: Sidebar.init({ id: "foldworks-sidebar", defaultCollapsed: route._tag === "Theme" }),
uiKit: initialUiKit,
+ mobileForm: initMobileForm(),
themeBuilder: initialThemeBuilder(),
themeName: theme.name,
themePreference: theme.preference,
diff --git a/apps/demo/src/app/route.test.ts b/apps/demo/src/app/route.test.ts
index 50ce2ea..4628c1e 100644
--- a/apps/demo/src/app/route.test.ts
+++ b/apps/demo/src/app/route.test.ts
@@ -23,6 +23,7 @@ import {
pdfViewerRouter,
statechartRouter,
uiKitRouter,
+ mobileFormRouter,
themeRouter,
urlToAppRoute,
workflowOrientationFromRoute,
@@ -78,6 +79,8 @@ describe("demo routes", () => {
);
expect(uiKitRouter()).toBe("/ui-kit");
expect(themeRouter()).toBe("/theme");
+ expect(mobileFormRouter()).toBe("/mobile-form");
+ expect(demoFromRoute(parseUrl("https://demo.test/mobile-form"))).toBe("MobileForm");
expect(demoFromRoute(parseUrl("https://demo.test/theme#theme=state"))).toBe("Theme");
expect(queryBuilderRouter()).toBe("/query-builder");
expect(pdfAnnotatorRouter()).toBe("/pdf-annotator");
diff --git a/apps/demo/src/app/route.ts b/apps/demo/src/app/route.ts
index 48bc0f9..e004ab1 100644
--- a/apps/demo/src/app/route.ts
+++ b/apps/demo/src/app/route.ts
@@ -26,6 +26,7 @@ export type Demo =
| "PdfViewer"
| "DocumentAnalysis"
| "UiKit"
+ | "MobileForm"
| "Theme";
export const WorkflowOrientation = S.Literals(["Vertical", "Horizontal"]);
@@ -55,6 +56,7 @@ export const AppRoute = defineRouteUnion({
PdfViewer: {},
DocumentAnalysis: {},
UiKit: {},
+ MobileForm: {},
Theme: {},
NotFound: { path: S.String },
});
@@ -130,6 +132,11 @@ export const themeRouter = pipe(Route.literal("theme"), Route.mapTo(AppRoute.The
export const uiKitRouter = pipe(Route.literal("ui-kit"), Route.mapTo(AppRoute.UiKit));
+export const mobileFormRouter = pipe(
+ Route.literal("mobile-form"),
+ Route.mapTo(AppRoute.MobileForm),
+);
+
export const queryBuilderRouter = pipe(
Route.literal("query-builder"),
Route.mapTo(AppRoute.QueryBuilder),
@@ -171,6 +178,7 @@ const routeParser = Route.oneOf(
pdfViewerRouter,
documentAnalysisRouter,
uiKitRouter,
+ mobileFormRouter,
themeRouter,
homeRouter,
);
@@ -217,6 +225,8 @@ export const demoFromRoute = (route: AppRoute): Demo => {
return "Theme";
case "UiKit":
return "UiKit";
+ case "MobileForm":
+ return "MobileForm";
case "Workflow":
return "Workflow";
case "Statechart":
diff --git a/apps/demo/src/app/update.ts b/apps/demo/src/app/update.ts
index ced8bf5..e9dd343 100644
--- a/apps/demo/src/app/update.ts
+++ b/apps/demo/src/app/update.ts
@@ -34,6 +34,8 @@ import { update as updateStatechart } from "../statechart/update";
import { update as updateThemeBuilder } from "../theme-builder/update";
import { Message as ThemeBuilderMessage } from "../theme-builder/message";
import { update as updateUiKit } from "../ui-kit/update";
+import { Message as MobileFormMessage } from "../mobile-form/message";
+import { update as updateMobileForm } from "../mobile-form/update";
import { OutMessage as WorkflowOutMessage } from "../workflow/message";
import { setOrientation, update as updateWorkflow } from "../workflow/update";
import {
@@ -161,17 +163,26 @@ const foldThemeBuilder = Update.foldChild({
toParentMessage: (message) => Message.GotThemeBuilderMessage({ message }),
});
+const foldMobileForm = Update.foldChild({
+ update: updateMobileForm,
+ read: (model: Model) => Option.some(model.mobileForm),
+ write: (model, mobileForm) => ({ ...model, mobileForm }),
+ toParentMessage: (message) => Message.GotMobileFormMessage({ message }),
+});
+
/** Commands to run when a route is entered, after its child state exists. */
export const enterRoute = (model: Model): UpdateReturn =>
- model.route._tag === "Theme"
- ? foldThemeBuilder(model, ThemeBuilderMessage.Initialize())
- : model.route._tag === "Statechart"
- ? foldStatechart(model, StatechartMessage.ClickedFit())
- : model.route._tag === "PdfViewer"
- ? enterPdfViewer(model)
- : model.route._tag === "DocumentAnalysis"
- ? foldDocumentAnalysis(model, DocumentAnalysisMessage.Initialize())
- : { model };
+ model.route._tag === "MobileForm"
+ ? foldMobileForm(model, MobileFormMessage.Initialize())
+ : model.route._tag === "Theme"
+ ? foldThemeBuilder(model, ThemeBuilderMessage.Initialize())
+ : model.route._tag === "Statechart"
+ ? foldStatechart(model, StatechartMessage.ClickedFit())
+ : model.route._tag === "PdfViewer"
+ ? enterPdfViewer(model)
+ : model.route._tag === "DocumentAnalysis"
+ ? foldDocumentAnalysis(model, DocumentAnalysisMessage.Initialize())
+ : { model };
const foldCodeEditor = Update.foldChild({
update: updateCodeEditor,
@@ -431,6 +442,7 @@ export const update = (model: Model, message: Message): UpdateReturn =>
GotSidebarMessage: ({ message: childMessage }) => foldSidebar(model, childMessage),
GotThemeBuilderMessage: ({ message }) => foldThemeBuilder(model, message),
GotUiKitMessage: ({ message: childMessage }) => foldUiKit(model, childMessage),
+ GotMobileFormMessage: ({ message }) => foldMobileForm(model, message),
GotGenerativeUiAction: ({ intent }) => ({
model: modifyFields(model, {
announcement: () =>
diff --git a/apps/demo/src/app/view.ts b/apps/demo/src/app/view.ts
index 316e743..4826342 100644
--- a/apps/demo/src/app/view.ts
+++ b/apps/demo/src/app/view.ts
@@ -23,6 +23,7 @@ import {
Moon,
Network,
Palette,
+ Smartphone,
Sun,
Table2,
Workflow as WorkflowIcon,
@@ -51,6 +52,7 @@ import { view as queryBuilderView } from "../query-builder/view";
import { statechartSummary, view as statechartView } from "../statechart/view";
import { view as themeBuilderView } from "../theme-builder/view";
import { view as uiKitView } from "../ui-kit/view";
+import { view as mobileFormView } from "../mobile-form/view";
import { allNodes } from "../workflow/graph";
import {
editorRouter,
@@ -72,6 +74,7 @@ import {
queryBuilderRouter,
statechartRouter,
uiKitRouter,
+ mobileFormRouter,
themeRouter,
workflowPath,
type Demo,
@@ -367,6 +370,13 @@ const navigationGroups = (model: Model): ReadonlyArray
icon: Blocks,
isActive: demo === "UiKit",
},
+ {
+ id: "mobile-form",
+ label: "Mobile inputs",
+ href: mobileFormRouter(),
+ icon: Smartphone,
+ isActive: demo === "MobileForm",
+ },
],
// The builder exposes the same token layer as the theme picker.
Tokens: [
@@ -839,6 +849,13 @@ const content = (model: Model, h: HtmlBuilder): Html => {
toParentMessage: (message) => Message.GotPdfViewerDemoMessage({ message }),
});
}
+ if (demo === "MobileForm")
+ return h.submodel({
+ slotId: "mobile-form-content",
+ model: model.mobileForm,
+ view: mobileFormView,
+ toParentMessage: (message) => Message.GotMobileFormMessage({ message }),
+ });
return h.submodel({
slotId: "ui-kit-content",
model: model.uiKit,
@@ -848,32 +865,34 @@ const content = (model: Model, h: HtmlBuilder): Html => {
};
const documentTitle = (demo: Demo): string =>
- demo === "Theme"
- ? "Theme builder · Foldworks"
- : demo === "DocumentAnalysis"
- ? "Document analysis · Foldworks"
- : Match.value(demo).pipe(
- Match.when("Docs", () => "Documentation · Foldworks"),
- Match.when("Editor", () => "Document editor · Foldworks"),
- Match.when("Orchestrator", () => "Outline workspace · Foldworks"),
- Match.when("Agent", () => "Interactive agent · Foldworks"),
- Match.when("CodeEditor", () => "Code editor · Foldworks"),
- Match.when("Codebase", () => "Codebase workbench · Foldworks"),
- Match.when("DiffViewer", () => "Change request · Foldworks"),
- Match.when("Workbench", () => "Workers workbench · Foldworks"),
- Match.when("Home", () => "Foldworks · The UI standard library for Foldkit and StyleX"),
- Match.when("DataTable", () => "Data table · Foldworks"),
- Match.when("DataGrid", () => "Data grid · Foldworks"),
- Match.when("FormBuilder", () => "Form builder · Foldworks"),
- Match.when("QueryBuilder", () => "Query builder · Foldworks"),
- Match.when("Outliner", () => "Outliner · Foldworks"),
- Match.when("PdfAnnotator", () => "PDF annotator · Foldworks"),
- Match.when("PdfViewer", () => "PDF viewer · Foldworks"),
- Match.when("UiKit", () => "UI components · Foldworks"),
- Match.when("Workflow", () => "Workflow · Foldworks"),
- Match.when("Statechart", () => "Statechart · Foldworks"),
- Match.exhaustive,
- );
+ demo === "MobileForm"
+ ? "Mobile inputs · Foldworks"
+ : demo === "Theme"
+ ? "Theme builder · Foldworks"
+ : demo === "DocumentAnalysis"
+ ? "Document analysis · Foldworks"
+ : Match.value(demo).pipe(
+ Match.when("Docs", () => "Documentation · Foldworks"),
+ Match.when("Editor", () => "Document editor · Foldworks"),
+ Match.when("Orchestrator", () => "Outline workspace · Foldworks"),
+ Match.when("Agent", () => "Interactive agent · Foldworks"),
+ Match.when("CodeEditor", () => "Code editor · Foldworks"),
+ Match.when("Codebase", () => "Codebase workbench · Foldworks"),
+ Match.when("DiffViewer", () => "Change request · Foldworks"),
+ Match.when("Workbench", () => "Workers workbench · Foldworks"),
+ Match.when("Home", () => "Foldworks · The UI standard library for Foldkit and StyleX"),
+ Match.when("DataTable", () => "Data table · Foldworks"),
+ Match.when("DataGrid", () => "Data grid · Foldworks"),
+ Match.when("FormBuilder", () => "Form builder · Foldworks"),
+ Match.when("QueryBuilder", () => "Query builder · Foldworks"),
+ Match.when("Outliner", () => "Outliner · Foldworks"),
+ Match.when("PdfAnnotator", () => "PDF annotator · Foldworks"),
+ Match.when("PdfViewer", () => "PDF viewer · Foldworks"),
+ Match.when("UiKit", () => "UI components · Foldworks"),
+ Match.when("Workflow", () => "Workflow · Foldworks"),
+ Match.when("Statechart", () => "Statechart · Foldworks"),
+ Match.exhaustive,
+ );
export const view = (model: Model, h: HtmlBuilder): Document => {
const demo = demoFromRoute(model.route);
diff --git a/apps/demo/src/entry.ts b/apps/demo/src/entry.ts
index a430afb..dc92c2a 100644
--- a/apps/demo/src/entry.ts
+++ b/apps/demo/src/entry.ts
@@ -1,6 +1,7 @@
import { Runtime } from "foldkit";
import { Message, Model, init, subscriptions, update, view } from "./main";
+import { slowConfig } from "./mobile-form/performance";
const application = Runtime.makeApplication({
Model,
@@ -20,11 +21,9 @@ const application = Runtime.makeApplication({
// The editor owns large document snapshots; recording each keystroke stalls the demo in development.
excludeFromHistory: ["GotCodeEditorMessage"],
},
- // Keep slow-phase warnings useful without sending the whole editor model through Vite's console bridge.
- slow: {
- onSlow: ({ _tag, durationMs }) =>
- console.warn(`[foldkit] Slow ${_tag}: ${durationMs.toFixed(1)}ms`),
- },
+ // Record every phase for the mobile form's live timings; warnings stay development-only
+ // and never send the whole editor model through Vite's console bridge.
+ slow: slowConfig(),
viewTransition: ({ previousModel, model }) => previousModel.revision !== model.revision,
});
diff --git a/apps/demo/src/mobile-form/form-view.ts b/apps/demo/src/mobile-form/form-view.ts
new file mode 100644
index 0000000..6a16d2e
--- /dev/null
+++ b/apps/demo/src/mobile-form/form-view.ts
@@ -0,0 +1,666 @@
+import type { Html, HtmlBuilder } from "foldkit/html";
+import { Check, ChevronLeft, Search } from "@lucide/icons";
+import * as Dialog from "@foldkit/ui/dialog";
+import * as Icon from "@foldworks/ui/icon";
+import { Option } from "effect";
+import { formatPhone } from "./format";
+import {
+ bottomSheet,
+ choiceCards,
+ reveal,
+ yesNo,
+ chips,
+ codeInput,
+ KeyboardInset,
+ pickerTrigger,
+ stepper,
+ textField,
+ toggle,
+} from "./inputs";
+import { Message } from "./message";
+import {
+ formSteps,
+ type Employment,
+ type FieldId,
+ type Model,
+ type Shift,
+ type YesNoQuestion,
+ type TextFieldId,
+} from "./model";
+import { filterStates, stateName, usStates } from "./states";
+import { inputStyles } from "./inputs.styles";
+import { className, styles } from "./styles";
+import { stepTitle } from "./update";
+import { emailFix, fieldError } from "./validation";
+
+type H = HtmlBuilder;
+
+const errorFor = (model: Model, field: FieldId): string | undefined =>
+ model.visited.includes(field) ? fieldError(field, model.values, model.today) : undefined;
+
+const text = (model: Model, field: TextFieldId) => ({
+ id: field,
+ value: model.values.text[field],
+ onInput: (value: string) => Message.ChangedText({ field, value }),
+ onBlur: Message.LeftField({ field }),
+ error: errorFor(model, field),
+});
+
+const about = (model: Model, h: H): ReadonlyArray => {
+ const fix = emailFix(model.values.text.email);
+ return [
+ h.div(
+ [h.Class(className(styles.pair))],
+ [
+ textField(
+ {
+ ...text(model, "first-name"),
+ label: "First name",
+ autocomplete: "given-name",
+ autocapitalize: "words",
+ next: "last-name",
+ },
+ h,
+ ),
+ textField(
+ {
+ ...text(model, "last-name"),
+ label: "Last name",
+ autocomplete: "family-name",
+ autocapitalize: "words",
+ next: "email",
+ },
+ h,
+ ),
+ ],
+ ),
+ textField(
+ {
+ ...text(model, "email"),
+ label: "Email",
+ type: "email",
+ inputMode: "email",
+ autocomplete: "email",
+ autocapitalize: "none",
+ spellcheck: false,
+ next: "phone",
+ after:
+ fix === undefined
+ ? undefined
+ : h.button(
+ [
+ h.Type("button"),
+ h.Class(className(inputStyles.suggestion)),
+ h.OnClick(Message.AcceptedEmailFix({ email: fix })),
+ ],
+ ["Did you mean ", h.strong([], [fix]), "?"],
+ ),
+ },
+ h,
+ ),
+ textField(
+ {
+ ...text(model, "phone"),
+ label: "Mobile phone",
+ hint: "We'll text a code to confirm it's you.",
+ type: "tel",
+ inputMode: "tel",
+ autocomplete: "tel-national",
+ placeholder: "(555) 123-4567",
+ mask: "Phone",
+ next: "dob",
+ },
+ h,
+ ),
+ textField(
+ {
+ ...text(model, "dob"),
+ label: "Date of birth",
+ inputMode: "numeric",
+ autocomplete: "bday",
+ placeholder: "MM/DD/YYYY",
+ mask: "Date",
+ },
+ h,
+ ),
+ ];
+};
+
+const address = (model: Model, h: H): ReadonlyArray => [
+ textField(
+ {
+ ...text(model, "street"),
+ label: "Street address",
+ autocomplete: "address-line1",
+ autocapitalize: "words",
+ next: "apt",
+ },
+ h,
+ ),
+ textField(
+ {
+ ...text(model, "apt"),
+ label: "Apt, suite, unit",
+ optional: true,
+ autocomplete: "address-line2",
+ next: "city",
+ },
+ h,
+ ),
+ textField(
+ {
+ ...text(model, "city"),
+ label: "City",
+ autocomplete: "address-level2",
+ autocapitalize: "words",
+ next: "zip",
+ },
+ h,
+ ),
+ h.div(
+ [h.Class(className(styles.pair))],
+ [
+ pickerTrigger(
+ {
+ id: "state",
+ label: "State",
+ displayValue: stateName(model.values.state),
+ placeholder: "Choose",
+ onOpen: Message.OpenedStateSheet(),
+ error: errorFor(model, "state"),
+ autofill: {
+ autocomplete: "address-level1",
+ value: model.values.state,
+ options: usStates.map((state) => ({ value: state.code, label: state.name })),
+ onChange: (code) => Message.AutofilledState({ code }),
+ },
+ },
+ h,
+ ),
+ textField(
+ {
+ ...text(model, "zip"),
+ label: "ZIP code",
+ inputMode: "numeric",
+ autocomplete: "postal-code",
+ mask: "Zip",
+ maxLength: 10,
+ },
+ h,
+ ),
+ ],
+ ),
+];
+
+const employment: ReadonlyArray<{ value: Employment; label: string; description: string }> = [
+ { value: "full-time", label: "Full-time", description: "35+ hours a week, with benefits" },
+ { value: "part-time", label: "Part-time", description: "Flexible hours around your schedule" },
+ { value: "seasonal", label: "Seasonal", description: "Peak periods, a few months at a time" },
+];
+
+const shifts: ReadonlyArray<{ value: Shift; label: string }> = [
+ { value: "mornings", label: "Mornings" },
+ { value: "afternoons", label: "Afternoons" },
+ { value: "evenings", label: "Evenings" },
+ { value: "overnight", label: "Overnight" },
+ { value: "weekends", label: "Weekends" },
+];
+
+const work = (model: Model, h: H): ReadonlyArray => [
+ choiceCards(
+ {
+ name: "employment",
+ legend: "How do you want to work?",
+ options: employment,
+ value: model.values.employment,
+ onSelect: (value) => Message.SelectedEmployment({ value }),
+ error: errorFor(model, "employment"),
+ },
+ h,
+ ),
+ stepper(
+ {
+ id: "hours",
+ label: "Hours per week",
+ value: model.values.hours,
+ min: 1,
+ max: 60,
+ suffix: "hrs",
+ onStep: (delta) => Message.SteppedHours({ delta }),
+ onInput: (value) => Message.ChangedHours({ value }),
+ error: errorFor(model, "hours"),
+ },
+ h,
+ ),
+ textField(
+ {
+ ...text(model, "start-date"),
+ label: "Earliest start date",
+ type: "date",
+ autocomplete: "off",
+ ...(model.today === "" ? {} : { min: model.today }),
+ },
+ h,
+ ),
+ chips(
+ {
+ name: "shift",
+ legend: "Which shifts work for you?",
+ options: shifts,
+ selected: model.values.shifts,
+ onToggle: (shift) => Message.ToggledShift({ shift }),
+ error: errorFor(model, "shifts"),
+ },
+ h,
+ ),
+ toggle(
+ {
+ id: "alerts",
+ label: "Text me shift alerts",
+ description: "Open shifts near you, up to 3 a week.",
+ checked: model.values.alerts,
+ onToggle: (value) => Message.ToggledAlerts({ value }),
+ },
+ h,
+ ),
+];
+
+const question = (
+ model: Model,
+ name: YesNoQuestion,
+ legend: string,
+ followUp: ReadonlyArray,
+ h: H,
+): Html =>
+ h.div(
+ [h.Class(className(inputStyles.question))],
+ [
+ yesNo(
+ {
+ name,
+ legend,
+ value: model.values.answers[name],
+ onAnswer: (answer) => Message.Answered({ question: name, answer }),
+ error: errorFor(model, name),
+ },
+ h,
+ ),
+ reveal(
+ {
+ id: `${name}-follow-up`,
+ isOpen: model.values.answers[name] === "yes",
+ children: followUp,
+ },
+ h,
+ ),
+ ],
+ );
+
+const background = (model: Model, h: H): ReadonlyArray => [
+ question(
+ model,
+ "license",
+ "Do you have a valid driver's license?",
+ [
+ textField(
+ {
+ ...text(model, "license-number"),
+ label: "License number",
+ autocomplete: "off",
+ autocapitalize: "characters",
+ spellcheck: false,
+ next: `cdl-${model.values.answers.cdl || "yes"}`,
+ },
+ h,
+ ),
+ // Conditions nest: the class only matters for a commercial license.
+ question(
+ model,
+ "cdl",
+ "Is it a commercial license (CDL)?",
+ [
+ choiceCards(
+ {
+ name: "cdl-class",
+ legend: "CDL class",
+ options: [
+ { value: "a", label: "Class A", description: "Tractor-trailers and combinations" },
+ { value: "b", label: "Class B", description: "Straight trucks and buses" },
+ { value: "c", label: "Class C", description: "Passenger vans and hazmat" },
+ ],
+ value: model.values.cdlClass,
+ onSelect: (value) => Message.SelectedCdlClass({ value }),
+ error: errorFor(model, "cdl-class"),
+ },
+ h,
+ ),
+ ],
+ h,
+ ),
+ ],
+ h,
+ ),
+ question(
+ model,
+ "rehire",
+ "Have you worked for us before?",
+ [
+ textField(
+ {
+ ...text(model, "previous-location"),
+ label: "Which location?",
+ autocomplete: "off",
+ autocapitalize: "words",
+ next: "previous-end",
+ },
+ h,
+ ),
+ textField(
+ {
+ ...text(model, "previous-end"),
+ label: "When did you leave?",
+ inputMode: "numeric",
+ autocomplete: "off",
+ placeholder: "MM/YYYY",
+ mask: "Month",
+ },
+ h,
+ ),
+ ],
+ h,
+ ),
+];
+
+const verify = (model: Model, h: H): ReadonlyArray => [
+ h.p(
+ [h.Class(className(styles.lede))],
+ [
+ "Enter the 6-digit code we sent to ",
+ h.strong([], [formatPhone(model.values.text.phone) || "your phone"]),
+ ". ",
+ h.button(
+ [
+ h.Type("button"),
+ h.Class(className(styles.inlineLink)),
+ h.OnClick(Message.PressedEditPhone()),
+ ],
+ ["Edit"],
+ ),
+ ],
+ ),
+ codeInput(
+ {
+ id: "otp",
+ label: "Verification code",
+ value: model.values.text.otp,
+ length: 6,
+ onInput: (value) => Message.ChangedText({ field: "otp", value }),
+ error: errorFor(model, "otp"),
+ },
+ h,
+ ),
+ h.button(
+ [h.Type("button"), h.Class(className(styles.secondary)), h.OnClick(Message.PressedResend())],
+ ["Resend code"],
+ ),
+];
+
+const done = (model: Model, h: H): Html => {
+ const values = model.values;
+ const rows: ReadonlyArray = [
+ ["Name", `${values.text["first-name"]} ${values.text["last-name"]}`],
+ ["Email", values.text.email],
+ ["Phone", values.text.phone],
+ ["State", stateName(values.state)],
+ ];
+ return h.div(
+ [h.Class(className(styles.done))],
+ [
+ h.div(
+ [h.Class(className(styles.doneBadge))],
+ [Icon.view({ icon: Check, size: 36, strokeWidth: 3 }, h)],
+ ),
+ h.h1(
+ [h.Id("mobile-form-heading"), h.Tabindex(-1), h.Class(className(styles.heading))],
+ [stepTitle("Done")],
+ ),
+ h.p(
+ [h.Class(className(styles.asideText))],
+ ["Thanks for applying. We'll text you about next steps."],
+ ),
+ h.dl(
+ [h.Class(className(styles.summary))],
+ rows.flatMap(([term, value]) => [
+ h.dt([h.Class(className(styles.summaryTerm))], [term]),
+ h.dd([h.Class(className(styles.summaryValue))], [value]),
+ ]),
+ ),
+ h.button(
+ [
+ h.Type("button"),
+ h.Class(className(styles.secondary)),
+ h.OnClick(Message.PressedStartOver()),
+ ],
+ ["Start over"],
+ ),
+ ],
+ );
+};
+
+const ledes: Readonly, string>> = {
+ About: "It takes about two minutes. Your progress is saved as you go.",
+ Address: "Autofill fills this whole page in one tap.",
+ Work: "Pick everything that could work. You can change it later.",
+ Background: "Answer yes and we'll ask a follow-up. Answer no and it disappears.",
+};
+
+const stepBody = (model: Model, h: H): ReadonlyArray => {
+ switch (model.step) {
+ case "About":
+ return about(model, h);
+ case "Address":
+ return address(model, h);
+ case "Work":
+ return work(model, h);
+ case "Background":
+ return background(model, h);
+ case "Verify":
+ return verify(model, h);
+ case "Done":
+ return [];
+ }
+};
+
+const topBar = (model: Model, h: H): Html => {
+ const index = formSteps.indexOf(model.step as (typeof formSteps)[number]);
+ const position = index === -1 ? formSteps.length : index + 1;
+ return h.header(
+ [h.Class(className(styles.top))],
+ [
+ index > 0
+ ? h.button(
+ [
+ h.Type("button"),
+ h.Class(className(styles.iconButton)),
+ h.AriaLabel("Back"),
+ h.OnClick(Message.PressedBack()),
+ ],
+ [Icon.view({ icon: ChevronLeft, size: 24 }, h)],
+ )
+ : h.span([], []),
+ h.div(
+ [h.Class(className(styles.progress))],
+ [
+ h.span(
+ [h.Class(className(styles.progressLabel))],
+ [index === -1 ? "Complete" : `Step ${position} of ${formSteps.length}`],
+ ),
+ h.div(
+ [
+ h.Class(className(styles.progressTrack)),
+ h.Role("progressbar"),
+ h.AriaLabel("Application progress"),
+ h.AriaValuemin(0),
+ h.AriaValuemax(formSteps.length),
+ h.AriaValuenow(index === -1 ? formSteps.length : index),
+ ],
+ [
+ h.div([
+ h.Class(className(styles.progressFill)),
+ h.Style({
+ transform: `scaleX(${(index === -1 ? formSteps.length : index + 1) / formSteps.length})`,
+ }),
+ ]),
+ ],
+ ),
+ ],
+ ),
+ h.span([], []),
+ ],
+ );
+};
+
+const stateSheet = (model: Model, h: H): Html => {
+ const matches = filterStates(model.stateQuery);
+ return h.submodel({
+ slotId: "mobile-form-state-sheet",
+ model: model.stateSheet,
+ view: Dialog.view,
+ toParentMessage: (message) => Message.GotStateSheetMessage({ message }),
+ viewInputs: bottomSheet(
+ {
+ title: "State",
+ content: (initialFocus) => [
+ h.div(
+ [h.Class(className(styles.sheetSearch))],
+ [
+ h.label(
+ [h.For("state-search"), h.Class(className(styles.srOnly))],
+ ["Search states"],
+ ),
+ h.div(
+ [h.Style({ position: "relative" })],
+ [
+ h.input([
+ ...initialFocus,
+ h.Id("state-search"),
+ h.Type("search"),
+ h.Placeholder("Search"),
+ h.Autocomplete("off"),
+ h.Autocapitalize("none"),
+ h.Spellcheck(false),
+ h.EnterKeyHint("done"),
+ h.Value(model.stateQuery),
+ h.Class(className(inputStyles.control, styles.searchControl)),
+ h.OnInput((value) => Message.ChangedStateQuery({ value })),
+ h.OnKeyDownPreventDefault((key) =>
+ key === "Enter" && matches[0] !== undefined
+ ? Option.some(Message.ChosenState({ code: matches[0].code }))
+ : Option.none(),
+ ),
+ ]),
+ h.span(
+ [h.Class(className(styles.searchIcon))],
+ [Icon.view({ icon: Search, size: 18 }, h)],
+ ),
+ ],
+ ),
+ ],
+ ),
+ matches.length === 0
+ ? h.p([h.Class(className(styles.sheetEmpty))], ["No matching state"])
+ : h.ul(
+ [h.Class(className(styles.sheetList)), h.AriaLabel("States")],
+ matches.map((state) =>
+ h.keyed("li")(
+ state.code,
+ [],
+ [
+ h.button(
+ [
+ h.Type("button"),
+ h.Class(className(styles.sheetRow)),
+ h.AriaPressed(model.values.state === state.code ? "true" : "false"),
+ h.OnClick(Message.ChosenState({ code: state.code })),
+ ],
+ [
+ h.span(
+ [],
+ [
+ state.name,
+ h.span([h.Class(className(styles.sheetCode))], [state.code]),
+ ],
+ ),
+ model.values.state === state.code
+ ? Icon.view({ icon: Check, size: 20, strokeWidth: 2.5 }, h)
+ : h.empty,
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ },
+ h,
+ ),
+ });
+};
+
+/** The form application itself, shared by the demo route and the standalone build. */
+export const formApp = (model: Model, h: H): Html =>
+ h.div(
+ [h.Class(className(styles.app)), h.OnMount(KeyboardInset())],
+ [
+ topBar(model, h),
+ h.div(
+ [h.Id("mobile-form-scroll"), h.Class(className(styles.scroll))],
+ [
+ model.step === "Done"
+ ? done(model, h)
+ : h.form(
+ [
+ h.Id("mobile-form"),
+ h.Class(className(styles.form)),
+ h.Novalidate(true),
+ h.OnSubmit(Message.Submitted()),
+ ],
+ [
+ h.keyed("div")(
+ model.step,
+ [h.Class(className(styles.body))],
+ [
+ h.h1(
+ [
+ h.Id("mobile-form-heading"),
+ h.Tabindex(-1),
+ h.Class(className(styles.heading)),
+ ],
+ [stepTitle(model.step)],
+ ),
+ model.step === "Verify"
+ ? h.empty
+ : h.p([h.Class(className(styles.lede))], [ledes[model.step]]),
+ ...stepBody(model, h),
+ ],
+ ),
+ h.div(
+ [h.Class(className(styles.actions))],
+ [
+ h.button(
+ [h.Type("submit"), h.Class(className(styles.primary))],
+ [model.step === "Verify" ? "Verify" : "Continue"],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ h.div(
+ [h.Class(className(styles.srOnly)), h.Role("status"), h.AriaLive("polite")],
+ [model.announcement],
+ ),
+ stateSheet(model, h),
+ ],
+ );
diff --git a/apps/demo/src/mobile-form/format.test.ts b/apps/demo/src/mobile-form/format.test.ts
new file mode 100644
index 0000000..ecba075
--- /dev/null
+++ b/apps/demo/src/mobile-form/format.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ ageOn,
+ caretAfterDigits,
+ emailSuggestion,
+ formatDate,
+ formatMonth,
+ formatPhone,
+ formatZip,
+ parseDate,
+ parseMonth,
+} from "./format";
+import { filterStates } from "./states";
+
+describe("mobile form formatting", () => {
+ it("formats phone numbers as they are typed and accepts autofilled country codes", () => {
+ expect(formatPhone("555")).toBe("555");
+ expect(formatPhone("5551")).toBe("(555) 1");
+ expect(formatPhone("5551234567")).toBe("(555) 123-4567");
+ expect(formatPhone("+1 (555) 123-4567")).toBe("(555) 123-4567");
+ expect(formatPhone("15551234567")).toBe("(555) 123-4567");
+ expect(formatPhone("555123456789")).toBe("(555) 123-4567");
+ });
+
+ it("formats dates and ZIP codes from digits alone", () => {
+ expect(formatDate("07")).toBe("07");
+ expect(formatDate("0704")).toBe("07/04");
+ expect(formatDate("07/04/1990")).toBe("07/04/1990");
+ expect(formatDate("0704199012")).toBe("07/04/1990");
+ expect(formatZip("94612-1234")).toBe("94612");
+ expect(formatMonth("06")).toBe("06");
+ expect(formatMonth("062022")).toBe("06/2022");
+ expect(parseMonth("06/2022")).toBe("2022-06");
+ expect(parseMonth("13/2022")).toBeUndefined();
+ });
+
+ it("places the caret after the same digit once separators move", () => {
+ expect(caretAfterDigits("(555) 129-3456", 6)).toBe(9);
+ expect(caretAfterDigits("(555) 123-4567", 3)).toBe(4);
+ expect(caretAfterDigits("(555) 1", 0)).toBe(1);
+ expect(caretAfterDigits("07/04", 4)).toBe(5);
+ });
+
+ it("validates real calendar dates and ages", () => {
+ expect(parseDate("02/29/2024")).toEqual({ year: 2024, month: 2, day: 29 });
+ expect(parseDate("02/29/2023")).toBeUndefined();
+ expect(parseDate("13/01/2000")).toBeUndefined();
+ expect(ageOn({ year: 2000, month: 7, day: 4 }, { year: 2016, month: 7, day: 3 })).toBe(15);
+ expect(ageOn({ year: 2000, month: 7, day: 4 }, { year: 2016, month: 7, day: 4 })).toBe(16);
+ });
+
+ it("suggests corrections for common email domain typos only", () => {
+ expect(emailSuggestion("ada@gmial.com")).toBe("ada@gmail.com");
+ expect(emailSuggestion("ada@hotmial.com")).toBe("ada@hotmail.com");
+ expect(emailSuggestion("ada@example.com")).toBeUndefined();
+ });
+
+ it("finds states by name, word prefix, or postal code", () => {
+ expect(filterStates("cal").map((state) => state.code)).toEqual(["CA"]);
+ expect(filterStates("york").map((state) => state.code)).toEqual(["NY"]);
+ expect(filterStates("tx").map((state) => state.code)).toEqual(["TX"]);
+ expect(filterStates("")).toHaveLength(51);
+ });
+});
diff --git a/apps/demo/src/mobile-form/format.ts b/apps/demo/src/mobile-form/format.ts
new file mode 100644
index 0000000..5db8c01
--- /dev/null
+++ b/apps/demo/src/mobile-form/format.ts
@@ -0,0 +1,119 @@
+/**
+ * Pure formatting and validation for the mobile candidate form. Masks keep
+ * only digits in the model's source of truth and derive the display string, so
+ * autofill, paste, and mid-string edits all converge on the same value.
+ */
+
+export const digitsOf = (value: string): string => value.replace(/\D/g, "");
+
+/** Accepts autofilled `+1 (555) 123-4567` and `15551234567` as ten national digits. */
+export const phoneDigits = (value: string): string => {
+ const digits = digitsOf(value);
+ return (digits.length > 10 && digits.startsWith("1") ? digits.slice(1) : digits).slice(0, 10);
+};
+
+export const formatPhone = (value: string): string => {
+ const d = phoneDigits(value);
+ if (d.length <= 3) return d;
+ if (d.length <= 6) return `(${d.slice(0, 3)}) ${d.slice(3)}`;
+ return `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}`;
+};
+
+export const formatDate = (value: string): string => {
+ const d = digitsOf(value).slice(0, 8);
+ if (d.length <= 2) return d;
+ if (d.length <= 4) return `${d.slice(0, 2)}/${d.slice(2)}`;
+ return `${d.slice(0, 2)}/${d.slice(2, 4)}/${d.slice(4)}`;
+};
+
+export const formatMonth = (value: string): string => {
+ const d = digitsOf(value).slice(0, 6);
+ return d.length <= 2 ? d : `${d.slice(0, 2)}/${d.slice(2)}`;
+};
+
+/** ZIP+4 from autofill keeps the five-digit ZIP. */
+export const formatZip = (value: string): string => digitsOf(value).slice(0, 5);
+
+export const formatOtp = (value: string): string => digitsOf(value).slice(0, 6);
+
+export const MaskKind = ["Phone", "Date", "Month", "Zip", "Otp"] as const;
+export type MaskKind = (typeof MaskKind)[number];
+
+export const formatMask = (kind: MaskKind, value: string): string => {
+ switch (kind) {
+ case "Phone":
+ return formatPhone(value);
+ case "Date":
+ return formatDate(value);
+ case "Month":
+ return formatMonth(value);
+ case "Zip":
+ return formatZip(value);
+ case "Otp":
+ return formatOtp(value);
+ }
+};
+
+/** The caret index in `formatted` just after its `digitCount`-th digit. */
+export const caretAfterDigits = (formatted: string, digitCount: number): number => {
+ if (digitCount <= 0) return formatted.search(/\d/) === -1 ? 0 : formatted.search(/\d/);
+ let seen = 0;
+ for (let index = 0; index < formatted.length; index++) {
+ if (/\d/.test(formatted[index]!)) seen++;
+ if (seen === digitCount) return index + 1;
+ }
+ return formatted.length;
+};
+
+const domainTypos: Readonly> = {
+ "gmial.com": "gmail.com",
+ "gmal.com": "gmail.com",
+ "gmai.com": "gmail.com",
+ "gamil.com": "gmail.com",
+ "gmail.co": "gmail.com",
+ "gmail.con": "gmail.com",
+ "hotmial.com": "hotmail.com",
+ "hotmal.com": "hotmail.com",
+ "yaho.com": "yahoo.com",
+ "yahooo.com": "yahoo.com",
+ "outlok.com": "outlook.com",
+ "iclod.com": "icloud.com",
+ "icloud.co": "icloud.com",
+};
+
+/** A corrected address for a well-known misspelled domain, if any. */
+export const emailSuggestion = (email: string): string | undefined => {
+ const at = email.lastIndexOf("@");
+ if (at < 1) return undefined;
+ const fix = domainTypos[email.slice(at + 1).toLowerCase()];
+ return fix === undefined ? undefined : `${email.slice(0, at)}@${fix}`;
+};
+
+export const isEmail = (value: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value);
+
+export type CalendarDate = Readonly<{ year: number; month: number; day: number }>;
+
+export const parseDate = (value: string): CalendarDate | undefined => {
+ const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value);
+ if (match === null) return undefined;
+ const [month, day, year] = [Number(match[1]), Number(match[2]), Number(match[3])];
+ const date = new Date(Date.UTC(year, month - 1, day));
+ return date.getUTCFullYear() === year &&
+ date.getUTCMonth() === month - 1 &&
+ date.getUTCDate() === day
+ ? { year, month, day }
+ : undefined;
+};
+
+/** A MM/YYYY month as a comparable `YYYY-MM` string. */
+export const parseMonth = (value: string): string | undefined => {
+ const match = /^(\d{2})\/(\d{4})$/.exec(value);
+ if (match === null) return undefined;
+ const month = Number(match[1]);
+ return month >= 1 && month <= 12 ? `${match[2]}-${match[1]}` : undefined;
+};
+
+export const ageOn = (birth: CalendarDate, today: CalendarDate): number =>
+ today.year -
+ birth.year -
+ (today.month < birth.month || (today.month === birth.month && today.day < birth.day) ? 1 : 0);
diff --git a/apps/demo/src/mobile-form/inputs.styles.ts b/apps/demo/src/mobile-form/inputs.styles.ts
new file mode 100644
index 0000000..157b29b
--- /dev/null
+++ b/apps/demo/src/mobile-form/inputs.styles.ts
@@ -0,0 +1,447 @@
+import * as stylex from "@stylexjs/stylex";
+
+const press = "@media (hover: none)";
+const motion = "@media (prefers-reduced-motion: no-preference)";
+const fadeIn = stylex.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } });
+const slideUp = stylex.keyframes({
+ from: { transform: "translateY(100%)" },
+ to: { transform: "none" },
+});
+const blink = stylex.keyframes({ "0%, 100%": { opacity: 1 }, "50%": { opacity: 0 } });
+
+/** Touch-first input styles: 16px+ text, 52px+ targets, and press feedback without hover. */
+export const inputStyles = stylex.create({
+ field: { display: "flex", flexDirection: "column", gap: 8, scrollMarginBlock: 96 },
+ labelRow: { display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8 },
+ label: { fontSize: 15, fontWeight: 600, color: "var(--foreground)", lineHeight: 1.3 },
+ optional: { fontSize: 14, fontWeight: 400, color: "var(--muted-foreground)" },
+ control: {
+ boxSizing: "border-box",
+ width: "100%",
+ minHeight: 54,
+ paddingBlock: 0,
+ paddingInline: 16,
+ borderWidth: 1.5,
+ borderStyle: "solid",
+ borderColor: { default: "var(--input)", ":focus": "var(--primary)" },
+ borderRadius: 12,
+ backgroundColor: "var(--background)",
+ color: "var(--foreground)",
+ // 17px matches iOS body text and keeps Safari from zooming on focus.
+ fontSize: 17,
+ fontFamily: "inherit",
+ lineHeight: 1.2,
+ outline: "none",
+ boxShadow: {
+ default: "none",
+ ":focus": "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)",
+ },
+ transition: { default: null, [motion]: "border-color 120ms, box-shadow 120ms" },
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ "::placeholder": { color: "color-mix(in oklch, var(--muted-foreground) 70%, transparent)" },
+ },
+ controlInvalid: {
+ borderColor: { default: "var(--destructive)", ":focus": "var(--destructive)" },
+ boxShadow: {
+ default: "none",
+ ":focus": "0 0 0 4px color-mix(in oklch, var(--destructive) 16%, transparent)",
+ },
+ },
+ numeric: { fontVariantNumeric: "tabular-nums", letterSpacing: "0.02em" },
+ date: { appearance: "none", minWidth: 0, display: "flex", alignItems: "center" },
+ hint: { fontSize: 14, color: "var(--muted-foreground)", lineHeight: 1.4 },
+ error: {
+ display: "flex",
+ gap: 6,
+ alignItems: "flex-start",
+ fontSize: 14,
+ fontWeight: 500,
+ color: "var(--destructive)",
+ lineHeight: 1.4,
+ },
+ errorIcon: { flexShrink: 0, marginTop: 2 },
+ suggestion: {
+ alignSelf: "flex-start",
+ minHeight: 40,
+ paddingBlock: 8,
+ paddingInline: 14,
+ borderWidth: 0,
+ borderRadius: 999,
+ backgroundColor: "color-mix(in oklch, var(--primary) 10%, var(--background))",
+ color: "var(--foreground)",
+ fontSize: 15,
+ fontFamily: "inherit",
+ textAlign: "left",
+ cursor: "pointer",
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ },
+ fieldset: {
+ display: "flex",
+ flexDirection: "column",
+ gap: 10,
+ margin: 0,
+ padding: 0,
+ borderWidth: 0,
+ minWidth: 0,
+ scrollMarginBlock: 96,
+ },
+ legend: { padding: 0, marginBottom: 2, fontSize: 15, fontWeight: 600 },
+ srOnly: {
+ position: "absolute",
+ width: 1,
+ height: 1,
+ margin: -1,
+ padding: 0,
+ overflow: "hidden",
+ clipPath: "inset(50%)",
+ whiteSpace: "nowrap",
+ borderWidth: 0,
+ },
+ card: {
+ position: "relative",
+ display: "grid",
+ gridTemplateColumns: "24px minmax(0, 1fr)",
+ gap: 14,
+ alignItems: "center",
+ minHeight: 64,
+ paddingBlock: 14,
+ paddingInline: 16,
+ borderWidth: 1.5,
+ borderStyle: "solid",
+ borderColor: "var(--input)",
+ borderRadius: 14,
+ backgroundColor: "var(--background)",
+ cursor: "pointer",
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ transform: { default: null, [press]: { default: null, ":active": "scale(0.985)" } },
+ transition: {
+ default: null,
+ [motion]: "transform 90ms, border-color 120ms, background-color 120ms",
+ },
+ ":has(input:focus-visible)": {
+ boxShadow: "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)",
+ },
+ },
+ cardSelected: {
+ borderColor: "var(--primary)",
+ backgroundColor: "color-mix(in oklch, var(--primary) 6%, var(--background))",
+ },
+ radio: {
+ appearance: "none",
+ flexShrink: 0,
+ margin: 0,
+ width: 22,
+ height: 22,
+ borderRadius: "50%",
+ borderWidth: 2,
+ borderStyle: "solid",
+ borderColor: { default: "var(--input)", ":checked": "var(--primary)" },
+ backgroundColor: { default: "var(--background)", ":checked": "var(--primary)" },
+ // A background-colored ring turns the filled circle into a radio dot in both modes.
+ boxShadow: { default: "none", ":checked": "inset 0 0 0 4px var(--background)" },
+ outline: "none",
+ },
+ cardTitle: { fontSize: 16, fontWeight: 600, lineHeight: 1.3 },
+ cardDescription: { fontSize: 14, color: "var(--muted-foreground)", lineHeight: 1.35 },
+ chips: { display: "flex", flexWrap: "wrap", gap: 8 },
+ chip: {
+ position: "relative",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 6,
+ minHeight: 44,
+ paddingBlock: 0,
+ paddingInline: 16,
+ borderWidth: 1.5,
+ borderStyle: "solid",
+ borderColor: "var(--input)",
+ borderRadius: 999,
+ fontSize: 15,
+ fontWeight: 500,
+ backgroundColor: "var(--background)",
+ cursor: "pointer",
+ userSelect: "none",
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ transform: { default: null, [press]: { default: null, ":active": "scale(0.96)" } },
+ transition: { default: null, [motion]: "transform 90ms, background-color 120ms" },
+ ":has(input:focus-visible)": {
+ boxShadow: "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)",
+ },
+ },
+ chipSelected: {
+ borderColor: "var(--primary)",
+ backgroundColor: "var(--primary)",
+ color: "var(--primary-foreground)",
+ },
+ yesNo: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 },
+ yesNoOption: {
+ position: "relative",
+ display: "grid",
+ placeItems: "center",
+ minHeight: 54,
+ borderWidth: 1.5,
+ borderStyle: "solid",
+ borderColor: "var(--input)",
+ borderRadius: 14,
+ backgroundColor: "var(--background)",
+ fontSize: 17,
+ fontWeight: 600,
+ cursor: "pointer",
+ userSelect: "none",
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ transform: { default: null, [press]: { default: null, ":active": "scale(0.97)" } },
+ transition: {
+ default: null,
+ [motion]: "transform 90ms, background-color 140ms, border-color 140ms, color 140ms",
+ },
+ ":has(input:focus-visible)": {
+ boxShadow: "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)",
+ },
+ },
+ yesNoSelected: {
+ borderColor: "var(--primary)",
+ backgroundColor: "var(--primary)",
+ color: "var(--primary-foreground)",
+ },
+ reveal: {
+ display: "grid",
+ gridTemplateRows: "0fr",
+ opacity: 0,
+ transition: {
+ default: null,
+ [motion]: "grid-template-rows 260ms cubic-bezier(.2,.8,.2,1), opacity 200ms ease-out",
+ },
+ },
+ revealOpen: { gridTemplateRows: "1fr", opacity: 1 },
+ // Room for focus rings, which the clip would otherwise cut off.
+ revealClip: { minHeight: 0, overflow: "hidden", margin: -6, padding: 6 },
+ // Follow-ups are indented under a rule so nested answers read as one branch.
+ revealBody: {
+ display: "flex",
+ flexDirection: "column",
+ gap: 22,
+ marginTop: 18,
+ paddingBlock: 2,
+ paddingLeft: 16,
+ borderLeftWidth: 2,
+ borderLeftStyle: "solid",
+ borderLeftColor: "var(--border)",
+ transform: "translateY(-8px)",
+ transition: { default: null, [motion]: "transform 260ms cubic-bezier(.2,.8,.2,1)" },
+ },
+ revealBodyOpen: { transform: "none" },
+ question: { display: "flex", flexDirection: "column" },
+ stepper: {
+ display: "grid",
+ gridTemplateColumns: "56px minmax(0, 1fr) 56px",
+ alignItems: "stretch",
+ minHeight: 56,
+ borderWidth: 1.5,
+ borderStyle: "solid",
+ borderColor: "var(--input)",
+ borderRadius: 14,
+ overflow: "hidden",
+ ":focus-within": {
+ borderColor: "var(--primary)",
+ boxShadow: "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)",
+ },
+ },
+ stepperButton: {
+ display: "grid",
+ placeItems: "center",
+ borderWidth: 0,
+ backgroundColor: { default: "var(--muted)", ":disabled": "var(--background)" },
+ color: { default: "var(--foreground)", ":disabled": "var(--muted-foreground)" },
+ cursor: "pointer",
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ transform: { default: null, [press]: { default: null, ":active": "scale(0.92)" } },
+ transition: { default: null, [motion]: "transform 90ms" },
+ },
+ stepperValue: {
+ display: "flex",
+ alignItems: "baseline",
+ justifyContent: "center",
+ gap: 6,
+ },
+ stepperInput: {
+ width: "3ch",
+ borderWidth: 0,
+ padding: 0,
+ backgroundColor: "transparent",
+ color: "var(--foreground)",
+ fontSize: 22,
+ fontWeight: 600,
+ fontFamily: "inherit",
+ fontVariantNumeric: "tabular-nums",
+ textAlign: "center",
+ outline: "none",
+ alignSelf: "center",
+ },
+ stepperSuffix: { fontSize: 15, color: "var(--muted-foreground)", alignSelf: "center" },
+ switchRow: {
+ display: "grid",
+ gridTemplateColumns: "minmax(0, 1fr) auto",
+ alignItems: "center",
+ gap: 16,
+ minHeight: 64,
+ paddingBlock: 12,
+ paddingInline: 16,
+ borderRadius: 14,
+ backgroundColor: "var(--muted)",
+ cursor: "pointer",
+ WebkitTapHighlightColor: "transparent",
+ },
+ switch: {
+ appearance: "none",
+ position: "relative",
+ margin: 0,
+ width: 51,
+ height: 31,
+ borderRadius: 999,
+ backgroundColor: { default: "var(--input)", ":checked": "var(--primary)" },
+ cursor: "pointer",
+ outline: { default: "none", ":focus-visible": "2px solid var(--ring)" },
+ outlineOffset: 2,
+ transition: { default: null, [motion]: "background-color 160ms" },
+ "::before": {
+ content: "''",
+ position: "absolute",
+ top: 2,
+ left: 2,
+ width: 27,
+ height: 27,
+ borderRadius: "50%",
+ backgroundColor: "#fff",
+ boxShadow: "0 2px 4px rgb(0 0 0 / 20%)",
+ transform: "translateX(0)",
+ transition: "transform 180ms cubic-bezier(.3,.7,.4,1)",
+ },
+ ":checked::before": { transform: "translateX(20px)" },
+ },
+ code: { position: "relative", display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 8 },
+ codeInput: {
+ position: "absolute",
+ inset: 0,
+ width: "100%",
+ height: "100%",
+ margin: 0,
+ padding: 0,
+ borderWidth: 0,
+ opacity: 0.01,
+ fontSize: 16,
+ color: "transparent",
+ caretColor: "transparent",
+ backgroundColor: "transparent",
+ outline: "none",
+ zIndex: 1,
+ },
+ codeCell: {
+ display: "grid",
+ placeItems: "center",
+ height: 60,
+ borderWidth: 1.5,
+ borderStyle: "solid",
+ borderColor: "var(--input)",
+ borderRadius: 12,
+ fontSize: 26,
+ fontWeight: 600,
+ fontVariantNumeric: "tabular-nums",
+ backgroundColor: "var(--background)",
+ },
+ codeCellActive: {
+ borderColor: {
+ default: "var(--input)",
+ [stylex.when.ancestor(":focus-within")]: "var(--primary)",
+ },
+ boxShadow: {
+ default: "none",
+ [stylex.when.ancestor(":focus-within")]:
+ "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)",
+ },
+ },
+ codeCaret: {
+ width: 2,
+ height: 28,
+ backgroundColor: "var(--primary)",
+ opacity: { default: 0, [stylex.when.ancestor(":focus-within")]: 1 },
+ animationName: { default: "none", [stylex.when.ancestor(":focus-within")]: blink },
+ animationDuration: "1s",
+ animationIterationCount: "infinite",
+ animationTimingFunction: "steps(1)",
+ },
+ trigger: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 8,
+ textAlign: "left",
+ cursor: "pointer",
+ },
+ triggerPlaceholder: { color: "color-mix(in oklch, var(--muted-foreground) 70%, transparent)" },
+ sheetRoot: {
+ position: "fixed",
+ inset: 0,
+ width: "100%",
+ height: "100%",
+ maxWidth: "none",
+ maxHeight: "none",
+ margin: 0,
+ padding: 0,
+ borderWidth: 0,
+ backgroundColor: "transparent",
+ overflow: "hidden",
+ },
+ sheetBackdrop: {
+ position: "absolute",
+ inset: 0,
+ backgroundColor: "rgb(0 0 0 / 40%)",
+ animationName: { default: "none", [motion]: fadeIn },
+ animationDuration: "200ms",
+ },
+ sheet: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: 12,
+ // A fixed height keeps the sheet from jumping while the list filters.
+ height: "min(72dvh, 600px)",
+ paddingTop: 22,
+ paddingInline: 16,
+ paddingBottom: "env(safe-area-inset-bottom)",
+ borderRadius: "20px 20px 0 0",
+ backgroundColor: "var(--popover, var(--background))",
+ color: "var(--popover-foreground, var(--foreground))",
+ boxShadow: "0 -8px 30px rgb(0 0 0 / 18%)",
+ animationName: { default: "none", [motion]: slideUp },
+ animationDuration: "260ms",
+ animationTimingFunction: "cubic-bezier(.2,.9,.25,1)",
+ "::before": {
+ content: "''",
+ position: "absolute",
+ top: 8,
+ left: "50%",
+ width: 36,
+ height: 5,
+ marginLeft: -18,
+ borderRadius: 999,
+ backgroundColor: "var(--border)",
+ },
+ },
+ sheetTitle: { margin: 0, fontSize: 17, fontWeight: 600, textAlign: "center" },
+ autofill: {
+ position: "absolute",
+ width: 1,
+ height: 1,
+ opacity: 0,
+ pointerEvents: "none",
+ },
+});
diff --git a/apps/demo/src/mobile-form/inputs.ts b/apps/demo/src/mobile-form/inputs.ts
new file mode 100644
index 0000000..8d04497
--- /dev/null
+++ b/apps/demo/src/mobile-form/inputs.ts
@@ -0,0 +1,657 @@
+import * as stylex from "@stylexjs/stylex";
+import { Effect, Option, Schema as S, Stream } from "effect";
+import { Mount } from "foldkit";
+import type { Html, HtmlBuilder } from "foldkit/html";
+import { Check, ChevronDown, CircleAlert, Minus, Plus } from "@lucide/icons";
+import * as Dialog from "@foldkit/ui/dialog";
+import * as Icon from "@foldworks/ui/icon";
+import { caretAfterDigits, digitsOf, formatMask, MaskKind } from "./format";
+import { inputStyles as styles } from "./inputs.styles";
+
+/**
+ * Stateless, touch-first form controls. Each control is one view function with
+ * callbacks for its Messages, so a parent adds a field with one call and owns
+ * its value; nothing here needs a Submodel, init, or update wiring.
+ */
+
+const cx = (...sx: ReadonlyArray): string =>
+ stylex.props(...sx).className ?? "";
+
+/**
+ * Formats masked input in place before Foldkit reads it, keeping the caret
+ * after the same digit. Deleting a separator deletes the digit before it.
+ * Capture listeners on the target run before the OnInput handler.
+ */
+export const MaskInput = Mount.defineStream("MobileMaskInput", {
+ args: { kind: S.Literals(MaskKind) },
+ messages: [S.Never],
+ execute: ({ kind, element }) =>
+ Stream.callback(() =>
+ Effect.gen(function* () {
+ yield* Effect.acquireRelease(
+ Effect.sync(() => {
+ const input = element as HTMLInputElement;
+ let before = input.value;
+ const onBeforeInput = () => {
+ before = input.value;
+ };
+ const onInput = (event: Event) => {
+ const raw = input.value;
+ const caret = input.selectionStart ?? raw.length;
+ let digits = digitsOf(raw);
+ let digitsBeforeCaret = digitsOf(raw.slice(0, caret)).length;
+ const deletedSeparator =
+ (event as InputEvent).inputType === "deleteContentBackward" &&
+ digits === digitsOf(before) &&
+ digitsBeforeCaret > 0;
+ if (deletedSeparator) {
+ digits = digits.slice(0, digitsBeforeCaret - 1) + digits.slice(digitsBeforeCaret);
+ digitsBeforeCaret -= 1;
+ }
+ const formatted = formatMask(kind, digits);
+ if (formatted === raw) return;
+ input.value = formatted;
+ if (document.activeElement === input) {
+ const position = caretAfterDigits(formatted, digitsBeforeCaret);
+ input.setSelectionRange(position, position);
+ }
+ };
+ input.addEventListener("beforeinput", onBeforeInput, true);
+ input.addEventListener("input", onInput, true);
+ return () => {
+ input.removeEventListener("beforeinput", onBeforeInput, true);
+ input.removeEventListener("input", onInput, true);
+ };
+ }),
+ (dispose) => Effect.sync(dispose),
+ );
+ return yield* Effect.never;
+ }),
+ ),
+});
+
+/**
+ * Publishes the on-screen keyboard height as `--keyboard-inset` so a bottom
+ * action bar can ride above it in Safari, which does not resize the layout
+ * viewport. Browsers that resize content report zero.
+ */
+export const KeyboardInset = Mount.defineStream("MobileKeyboardInset", {
+ messages: [S.Never],
+ execute: ({ element }) =>
+ Stream.callback(() =>
+ Effect.gen(function* () {
+ yield* Effect.acquireRelease(
+ Effect.sync(() => {
+ const viewport = window.visualViewport;
+ if (viewport === null) return () => {};
+ const target = element as HTMLElement;
+ const sync = () => {
+ const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
+ target.style.setProperty("--keyboard-inset", `${Math.round(inset)}px`);
+ };
+ viewport.addEventListener("resize", sync);
+ viewport.addEventListener("scroll", sync);
+ sync();
+ return () => {
+ viewport.removeEventListener("resize", sync);
+ viewport.removeEventListener("scroll", sync);
+ };
+ }),
+ (dispose) => Effect.sync(dispose),
+ );
+ return yield* Effect.never;
+ }),
+ ),
+});
+
+const errorId = (id: string) => `${id}-error`;
+const hintId = (id: string) => `${id}-hint`;
+
+const describedBy = (
+ id: string,
+ error: string | undefined,
+ hint: string | undefined,
+ h: HtmlBuilder,
+) => {
+ const ids = [
+ ...(error === undefined ? [] : [errorId(id)]),
+ ...(hint === undefined ? [] : [hintId(id)]),
+ ];
+ return ids.length === 0 ? [] : [h.AriaDescribedBy(ids.join(" "))];
+};
+
+const errorView = (id: string, error: string | undefined, h: HtmlBuilder) =>
+ error === undefined
+ ? h.empty
+ : h.p(
+ [h.Id(errorId(id)), h.Class(cx(styles.error))],
+ [Icon.view({ icon: CircleAlert, size: 16, sx: styles.errorIcon }, h), error],
+ );
+
+const hintView = (id: string, hint: string | undefined, h: HtmlBuilder) =>
+ hint === undefined ? h.empty : h.p([h.Id(hintId(id)), h.Class(cx(styles.hint))], [hint]);
+
+export type TextFieldConfig = Readonly<{
+ id: string;
+ label: string;
+ value: string;
+ onInput: (value: string) => Message;
+ onBlur: Message;
+ /** Focus moves here on Enter. Omit on the last field so Enter submits the form. */
+ next?: string;
+ error?: string | undefined;
+ hint?: string | undefined;
+ optional?: boolean;
+ type?: "text" | "email" | "tel" | "date";
+ inputMode?: "text" | "email" | "tel" | "numeric" | "decimal" | "search" | "none";
+ autocomplete: string;
+ autocapitalize?: "none" | "words" | "sentences" | "characters";
+ spellcheck?: boolean;
+ placeholder?: string;
+ mask?: MaskKind;
+ maxLength?: number;
+ min?: string | undefined;
+ after?: Html | undefined;
+}>;
+
+/** A labelled text input with the keyboard, autofill token, and Enter behavior a phone needs. */
+export const textField = (config: TextFieldConfig, h: HtmlBuilder) =>
+ h.keyed("div")(
+ config.id,
+ [h.Class(cx(styles.field))],
+ [
+ h.label(
+ [h.For(config.id), h.Class(cx(styles.labelRow))],
+ [
+ h.span([h.Class(cx(styles.label))], [config.label]),
+ config.optional === true ? h.span([h.Class(cx(styles.optional))], ["Optional"]) : h.empty,
+ ],
+ ),
+ hintView(config.id, config.hint, h),
+ h.input([
+ h.Id(config.id),
+ h.Name(config.id),
+ h.Type(config.type ?? "text"),
+ h.Value(config.value),
+ h.Class(
+ cx(
+ styles.control,
+ config.mask !== undefined && styles.numeric,
+ config.type === "date" && styles.date,
+ config.error !== undefined && styles.controlInvalid,
+ ),
+ ),
+ h.Autocomplete(config.autocomplete),
+ h.EnterKeyHint(config.next === undefined ? "done" : "next"),
+ ...(config.inputMode === undefined ? [] : [h.InputMode(config.inputMode)]),
+ ...(config.autocapitalize === undefined ? [] : [h.Autocapitalize(config.autocapitalize)]),
+ ...(config.spellcheck === false ? [h.Spellcheck(false), h.Autocorrect("off")] : []),
+ ...(config.placeholder === undefined ? [] : [h.Placeholder(config.placeholder)]),
+ ...(config.maxLength === undefined ? [] : [h.Maxlength(config.maxLength)]),
+ ...(config.min === undefined ? [] : [h.Min(config.min)]),
+ ...(config.optional === true ? [] : [h.AriaRequired(true)]),
+ h.AriaInvalid(config.error !== undefined),
+ ...describedBy(config.id, config.error, config.hint, h),
+ h.OnInput(config.onInput),
+ h.OnBlur(config.onBlur),
+ ...(config.next === undefined
+ ? []
+ : [
+ h.OnKeyDownFocus((key) =>
+ key === "Enter"
+ ? Option.some({ focusSelector: `#${config.next}`, message: config.onBlur })
+ : Option.none(),
+ ),
+ ]),
+ ...(config.mask === undefined ? [] : [h.OnMount(MaskInput({ kind: config.mask }))]),
+ ]),
+ errorView(config.id, config.error, h),
+ config.after ?? h.empty,
+ ],
+ );
+
+export type Choice = Readonly<{
+ value: Value;
+ label: string;
+ description?: string;
+}>;
+
+/** Native radios drawn as large cards: arrow keys, labels, and form semantics stay intact. */
+export const choiceCards = (
+ config: Readonly<{
+ name: string;
+ legend: string;
+ options: ReadonlyArray>;
+ value: string;
+ onSelect: (value: Value) => Message;
+ error?: string | undefined;
+ }>,
+ h: HtmlBuilder,
+) =>
+ h.keyed("fieldset")(
+ config.name,
+ [
+ h.Id(`${config.name}-group`),
+ h.Class(cx(styles.fieldset)),
+ ...describedBy(config.name, config.error, undefined, h),
+ ],
+ [
+ h.legend([h.Class(cx(styles.legend))], [config.legend]),
+ ...config.options.map((option) =>
+ h.label(
+ [h.Class(cx(styles.card, config.value === option.value && styles.cardSelected))],
+ [
+ h.input([
+ h.Id(`${config.name}-${option.value}`),
+ h.Type("radio"),
+ h.Name(config.name),
+ h.Value(option.value),
+ h.Checked(config.value === option.value),
+ h.Class(cx(styles.radio)),
+ h.OnChange(() => config.onSelect(option.value)),
+ ]),
+ h.span(
+ [],
+ [
+ h.span([h.Class(cx(styles.cardTitle))], [option.label]),
+ option.description === undefined
+ ? h.empty
+ : h.span(
+ [h.Class(cx(styles.cardDescription)), h.Style({ display: "block" })],
+ [option.description],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ errorView(config.name, config.error, h),
+ ],
+ );
+
+/** A required yes/no question: two native radios drawn as large side-by-side buttons. */
+export const yesNo = (
+ config: Readonly<{
+ name: string;
+ legend: string;
+ value: "" | "yes" | "no";
+ onAnswer: (answer: "yes" | "no") => Message;
+ error?: string | undefined;
+ }>,
+ h: HtmlBuilder,
+) =>
+ h.keyed("fieldset")(
+ config.name,
+ [
+ h.Class(cx(styles.fieldset)),
+ h.AriaInvalid(config.error !== undefined),
+ ...describedBy(config.name, config.error, undefined, h),
+ ],
+ [
+ h.legend([h.Class(cx(styles.legend))], [config.legend]),
+ h.div(
+ [h.Class(cx(styles.yesNo))],
+ (["yes", "no"] as const).map((answer) =>
+ h.label(
+ [h.Class(cx(styles.yesNoOption, config.value === answer && styles.yesNoSelected))],
+ [
+ h.input([
+ h.Id(`${config.name}-${answer}`),
+ h.Type("radio"),
+ h.Name(config.name),
+ h.Value(answer),
+ h.Checked(config.value === answer),
+ h.Class(cx(styles.srOnly)),
+ h.OnChange(() => config.onAnswer(answer)),
+ ]),
+ answer === "yes" ? "Yes" : "No",
+ ],
+ ),
+ ),
+ ),
+ errorView(config.name, config.error, h),
+ ],
+ );
+
+/**
+ * Shows or hides follow-up content with an animated expand and collapse. The
+ * content stays rendered so it can animate closed and keep its DOM state;
+ * while collapsed it is inert, so it cannot be focused or announced. Motion is
+ * pure CSS (`grid-template-rows` from 0fr to 1fr), so there is no animation
+ * state in the Model, and reduced-motion users get an instant change.
+ */
+export const reveal = (
+ config: Readonly<{ id: string; isOpen: boolean; children: ReadonlyArray }>,
+ h: HtmlBuilder,
+) =>
+ h.keyed("div")(
+ config.id,
+ [
+ h.Id(config.id),
+ h.Class(cx(styles.reveal, config.isOpen && styles.revealOpen)),
+ h.Inert(!config.isOpen),
+ h.AriaHidden(!config.isOpen),
+ ],
+ [
+ h.div(
+ [h.Class(cx(styles.revealClip))],
+ [
+ h.div(
+ [h.Class(cx(styles.revealBody, config.isOpen && styles.revealBodyOpen))],
+ config.children,
+ ),
+ ],
+ ),
+ ],
+ );
+
+/** Multi-select pills backed by checkboxes. */
+export const chips = (
+ config: Readonly<{
+ name: string;
+ legend: string;
+ options: ReadonlyArray>;
+ selected: ReadonlyArray;
+ onToggle: (value: Value) => Message;
+ error?: string | undefined;
+ }>,
+ h: HtmlBuilder,
+) =>
+ h.keyed("fieldset")(
+ config.name,
+ [h.Class(cx(styles.fieldset)), ...describedBy(config.name, config.error, undefined, h)],
+ [
+ h.legend([h.Class(cx(styles.legend))], [config.legend]),
+ h.div(
+ [h.Class(cx(styles.chips))],
+ config.options.map((option) => {
+ const isSelected = config.selected.includes(option.value);
+ return h.label(
+ [h.Class(cx(styles.chip, isSelected && styles.chipSelected))],
+ [
+ h.input([
+ h.Id(`${config.name}-${option.value}`),
+ h.Type("checkbox"),
+ h.Name(config.name),
+ h.Value(option.value),
+ h.Checked(isSelected),
+ h.Class(cx(styles.srOnly)),
+ h.OnChange(() => config.onToggle(option.value)),
+ ]),
+ isSelected ? Icon.view({ icon: Check, size: 16, strokeWidth: 2.5 }, h) : h.empty,
+ option.label,
+ ],
+ );
+ }),
+ ),
+ errorView(config.name, config.error, h),
+ ],
+ );
+
+/** Large minus/plus targets around a directly editable number. */
+export const stepper = (
+ config: Readonly<{
+ id: string;
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ suffix: string;
+ onStep: (delta: number) => Message;
+ onInput: (value: string) => Message;
+ error?: string | undefined;
+ }>,
+ h: HtmlBuilder,
+) =>
+ h.keyed("div")(
+ config.id,
+ [h.Class(cx(styles.field))],
+ [
+ h.label([h.For(config.id), h.Class(cx(styles.label))], [config.label]),
+ h.div(
+ [h.Class(cx(styles.stepper))],
+ [
+ h.button(
+ [
+ h.Type("button"),
+ h.Class(cx(styles.stepperButton)),
+ h.AriaLabel("Fewer hours"),
+ h.AriaControls(config.id),
+ h.Disabled(config.value <= config.min),
+ h.OnClick(config.onStep(-1)),
+ ],
+ [Icon.view({ icon: Minus, size: 22 }, h)],
+ ),
+ h.div(
+ [h.Class(cx(styles.stepperValue))],
+ [
+ h.input([
+ h.Id(config.id),
+ h.Type("text"),
+ h.InputMode("numeric"),
+ h.Pattern("[0-9]*"),
+ h.Maxlength(2),
+ h.Autocomplete("off"),
+ h.EnterKeyHint("done"),
+ h.Value(config.value === 0 ? "" : String(config.value)),
+ h.Class(cx(styles.stepperInput)),
+ h.AriaInvalid(config.error !== undefined),
+ ...describedBy(config.id, config.error, undefined, h),
+ h.OnInput(config.onInput),
+ ]),
+ h.span([h.Class(cx(styles.stepperSuffix)), h.AriaHidden(true)], [config.suffix]),
+ ],
+ ),
+ h.button(
+ [
+ h.Type("button"),
+ h.Class(cx(styles.stepperButton)),
+ h.AriaLabel("More hours"),
+ h.AriaControls(config.id),
+ h.Disabled(config.value >= config.max),
+ h.OnClick(config.onStep(1)),
+ ],
+ [Icon.view({ icon: Plus, size: 22 }, h)],
+ ),
+ ],
+ ),
+ errorView(config.id, config.error, h),
+ ],
+ );
+
+/** A checkbox with the `switch` attribute, which Safari renders natively with haptics. */
+export const toggle = (
+ config: Readonly<{
+ id: string;
+ label: string;
+ description: string;
+ checked: boolean;
+ onToggle: (checked: boolean) => Message;
+ }>,
+ h: HtmlBuilder,
+) =>
+ h.keyed("label")(
+ config.id,
+ [h.Class(cx(styles.switchRow)), h.For(config.id)],
+ [
+ h.span(
+ [],
+ [
+ h.span([h.Class(cx(styles.cardTitle)), h.Style({ display: "block" })], [config.label]),
+ h.span([h.Class(cx(styles.cardDescription))], [config.description]),
+ ],
+ ),
+ h.input([
+ h.Id(config.id),
+ h.Type("checkbox"),
+ h.Attribute("switch", ""),
+ h.Role("switch"),
+ h.Checked(config.checked),
+ h.Class(cx(styles.switch)),
+ h.OnChange(() => config.onToggle(!config.checked)),
+ ]),
+ ],
+ );
+
+/**
+ * One real input drawn as six cells, so SMS autofill, paste, and the numeric
+ * keypad behave exactly like a native one-time-code field.
+ */
+export const codeInput = (
+ config: Readonly<{
+ id: string;
+ label: string;
+ value: string;
+ length: number;
+ onInput: (value: string) => Message;
+ error?: string | undefined;
+ }>,
+ h: HtmlBuilder,
+) =>
+ h.keyed("div")(
+ config.id,
+ [h.Class(cx(styles.field))],
+ [
+ h.label([h.For(config.id), h.Class(cx(styles.srOnly))], [config.label]),
+ h.div(
+ [h.Class(stylex.props(styles.code, stylex.defaultMarker()).className ?? "")],
+ [
+ h.input([
+ h.Id(config.id),
+ h.Type("text"),
+ h.InputMode("numeric"),
+ h.Autocomplete("one-time-code"),
+ h.Pattern(`\\d{${config.length}}`),
+ h.Maxlength(config.length),
+ h.EnterKeyHint("done"),
+ h.Value(config.value),
+ h.Class(cx(styles.codeInput)),
+ h.AriaInvalid(config.error !== undefined),
+ ...describedBy(config.id, config.error, undefined, h),
+ h.OnInput(config.onInput),
+ h.OnMount(MaskInput({ kind: "Otp" })),
+ ]),
+ ...Array.from({ length: config.length }, (_, index) =>
+ h.span(
+ [
+ h.AriaHidden(true),
+ h.Class(
+ cx(
+ styles.codeCell,
+ index === Math.min(config.value.length, config.length - 1) &&
+ styles.codeCellActive,
+ ),
+ ),
+ ],
+ [
+ config.value[index] ??
+ (index === config.value.length
+ ? h.span([h.Class(cx(styles.codeCaret))], [])
+ : ""),
+ ],
+ ),
+ ),
+ ],
+ ),
+ errorView(config.id, config.error, h),
+ ],
+ );
+
+export type PickerTriggerConfig = Readonly<{
+ id: string;
+ label: string;
+ displayValue: string;
+ placeholder: string;
+ onOpen: Message;
+ error?: string | undefined;
+ /** A visually hidden native select so browser autofill can still fill the value. */
+ autofill: Readonly<{
+ autocomplete: string;
+ value: string;
+ options: ReadonlyArray>;
+ onChange: (value: string) => Message;
+ }>;
+}>;
+
+/** Looks like an input, opens a bottom sheet, and still participates in autofill. */
+export const pickerTrigger = (
+ config: PickerTriggerConfig,
+ h: HtmlBuilder,
+) =>
+ h.keyed("div")(
+ config.id,
+ [h.Class(cx(styles.field)), h.Style({ position: "relative" })],
+ [
+ h.span([h.Id(`${config.id}-label`), h.Class(cx(styles.label))], [config.label]),
+ h.button(
+ [
+ h.Id(config.id),
+ h.Type("button"),
+ h.Class(
+ cx(styles.control, styles.trigger, config.error !== undefined && styles.controlInvalid),
+ ),
+ h.AriaHasPopup("dialog"),
+ h.AriaLabelledBy(`${config.id}-label ${config.id}`),
+ h.AriaInvalid(config.error !== undefined),
+ ...describedBy(config.id, config.error, undefined, h),
+ h.OnClick(config.onOpen),
+ ],
+ [
+ h.span(config.displayValue === "" ? [h.Class(cx(styles.triggerPlaceholder))] : [], [
+ config.displayValue === "" ? config.placeholder : config.displayValue,
+ ]),
+ Icon.view({ icon: ChevronDown, size: 20 }, h),
+ ],
+ ),
+ h.select(
+ [
+ h.Id(`${config.id}-autofill`),
+ h.Name(config.id),
+ h.Autocomplete(config.autofill.autocomplete),
+ h.Tabindex(-1),
+ h.AriaHidden(true),
+ h.Class(cx(styles.autofill)),
+ h.OnChange(config.autofill.onChange),
+ ],
+ [
+ h.option([h.Value("")], [""]),
+ ...config.autofill.options.map((option) =>
+ h.option(
+ [h.Value(option.value), h.Selected(option.value === config.autofill.value)],
+ [option.label],
+ ),
+ ),
+ ],
+ ),
+ errorView(config.id, config.error, h),
+ ],
+ );
+
+/**
+ * A modal bottom sheet for the headless Foldkit Dialog: focus trap, Escape,
+ * backdrop dismissal, scroll lock, and focus return come from the Dialog.
+ */
+export const bottomSheet = (
+ config: Readonly<{
+ title: string;
+ content: (initialFocus: Dialog.RenderInfo["initialFocus"]) => ReadonlyArray;
+ }>,
+ h: HtmlBuilder,
+): Dialog.ViewInputs => ({
+ toView: ({ dialog, backdrop, panel, title, initialFocus, isVisible }) =>
+ h.dialog(
+ [...dialog, h.AriaModal(true), h.Class(cx(styles.sheetRoot))],
+ isVisible
+ ? [
+ h.div([...backdrop, h.Class(cx(styles.sheetBackdrop))]),
+ h.section(
+ [...panel, h.Class(cx(styles.sheet))],
+ [
+ h.h2([...title, h.Class(cx(styles.sheetTitle))], [config.title]),
+ ...config.content(initialFocus),
+ ],
+ ),
+ ]
+ : [],
+ ),
+});
diff --git a/apps/demo/src/mobile-form/message.ts b/apps/demo/src/mobile-form/message.ts
new file mode 100644
index 0000000..b5ffe81
--- /dev/null
+++ b/apps/demo/src/mobile-form/message.ts
@@ -0,0 +1,32 @@
+import { Schema as S } from "effect";
+import { defineMessageUnion } from "foldkit/message";
+import * as Dialog from "@foldkit/ui/dialog";
+import { CdlClass, Draft, Employment, FieldId, Shift, TextFieldId, YesNoQuestion } from "./model";
+
+export const Message = defineMessageUnion({
+ Initialize: {},
+ Restored: { today: S.String, draft: S.NullOr(Draft) },
+ ChangedText: { field: TextFieldId, value: S.String },
+ LeftField: { field: FieldId },
+ AcceptedEmailFix: { email: S.String },
+ SelectedEmployment: { value: Employment },
+ ChangedHours: { value: S.String },
+ SteppedHours: { delta: S.Number },
+ ToggledShift: { shift: Shift },
+ ToggledAlerts: { value: S.Boolean },
+ Answered: { question: YesNoQuestion, answer: S.Literals(["yes", "no"]) },
+ SelectedCdlClass: { value: CdlClass },
+ OpenedStateSheet: {},
+ ChangedStateQuery: { value: S.String },
+ ChosenState: { code: S.String },
+ AutofilledState: { code: S.String },
+ GotStateSheetMessage: { message: Dialog.Message },
+ Submitted: {},
+ PressedBack: {},
+ PressedEditPhone: {},
+ PressedResend: {},
+ PressedStartOver: {},
+ CompletedFocus: {},
+ CompletedPersist: {},
+});
+export type Message = typeof Message.Type;
diff --git a/apps/demo/src/mobile-form/model.ts b/apps/demo/src/mobile-form/model.ts
new file mode 100644
index 0000000..7d70441
--- /dev/null
+++ b/apps/demo/src/mobile-form/model.ts
@@ -0,0 +1,108 @@
+import { Schema as S } from "effect";
+import * as Dialog from "@foldkit/ui/dialog";
+
+export const Step = S.Literals(["About", "Address", "Work", "Background", "Verify", "Done"]);
+export type Step = typeof Step.Type;
+export const formSteps = ["About", "Address", "Work", "Background", "Verify"] as const;
+
+export const Employment = S.Literals(["full-time", "part-time", "seasonal"]);
+export type Employment = typeof Employment.Type;
+export const Shift = S.Literals(["mornings", "afternoons", "evenings", "overnight", "weekends"]);
+export type Shift = typeof Shift.Type;
+/** An unanswered question is the empty string, so "No" is a real answer. */
+export const YesNo = S.Literals(["", "yes", "no"]);
+export type YesNo = typeof YesNo.Type;
+export const YesNoQuestion = S.Literals(["license", "cdl", "rehire"]);
+export type YesNoQuestion = typeof YesNoQuestion.Type;
+export const CdlClass = S.Literals(["a", "b", "c"]);
+export type CdlClass = typeof CdlClass.Type;
+
+/** Text inputs share one change message; their ids double as DOM ids. */
+export const TextFieldId = S.Literals([
+ "first-name",
+ "last-name",
+ "email",
+ "phone",
+ "dob",
+ "street",
+ "apt",
+ "city",
+ "zip",
+ "start-date",
+ "license-number",
+ "previous-location",
+ "previous-end",
+ "otp",
+]);
+export type TextFieldId = typeof TextFieldId.Type;
+export const FieldId = S.Union([
+ TextFieldId,
+ S.Literals(["state", "employment", "hours", "shifts", "cdl-class"]),
+ YesNoQuestion,
+]);
+export type FieldId = typeof FieldId.Type;
+
+export const Values = S.Struct({
+ text: S.Record(TextFieldId, S.String),
+ state: S.String,
+ employment: S.String,
+ hours: S.Number,
+ shifts: S.Array(Shift),
+ alerts: S.Boolean,
+ answers: S.Record(YesNoQuestion, YesNo),
+ cdlClass: S.String,
+});
+export type Values = typeof Values.Type;
+
+/** What survives a reload, such as a candidate switching to Messages for the code. */
+export const Draft = S.Struct({ version: S.Literal(2), step: Step, values: Values });
+export type Draft = typeof Draft.Type;
+
+export const Model = S.Struct({
+ step: Step,
+ values: Values,
+ /** Fields whose errors are visible: blurred once, or the step was submitted. */
+ visited: S.Array(FieldId),
+ stateSheet: Dialog.Model,
+ stateQuery: S.String,
+ announcement: S.String,
+ /** ISO date read when the form loads; empty until then. */
+ today: S.String,
+});
+export type Model = typeof Model.Type;
+
+export const emptyValues: Values = {
+ text: {
+ "first-name": "",
+ "last-name": "",
+ email: "",
+ phone: "",
+ dob: "",
+ street: "",
+ apt: "",
+ city: "",
+ zip: "",
+ "start-date": "",
+ "license-number": "",
+ "previous-location": "",
+ "previous-end": "",
+ otp: "",
+ },
+ state: "",
+ employment: "",
+ hours: 20,
+ shifts: [],
+ alerts: true,
+ answers: { license: "", cdl: "", rehire: "" },
+ cdlClass: "",
+};
+
+export const init = (): Model => ({
+ step: "About",
+ values: emptyValues,
+ visited: [],
+ stateSheet: Dialog.init({ id: "mobile-form-state-sheet" }),
+ stateQuery: "",
+ announcement: "",
+ today: "",
+});
diff --git a/apps/demo/src/mobile-form/performance.ts b/apps/demo/src/mobile-form/performance.ts
new file mode 100644
index 0000000..acf957a
--- /dev/null
+++ b/apps/demo/src/mobile-form/performance.ts
@@ -0,0 +1,96 @@
+///
+import { Effect, Schema as S, Stream } from "effect";
+import { Mount } from "foldkit";
+import type { Runtime } from "foldkit";
+
+/**
+ * A measurement probe for the mobile form. The runtime reports every Update,
+ * View, and Patch duration through its `slow` hook, and an input listener
+ * records the time from each keystroke or tap to the next painted frame.
+ *
+ * The readout writes to the DOM directly instead of dispatching Messages, so
+ * displaying the numbers never causes the renders being measured.
+ */
+
+type Phase = "Update" | "View" | "Patch" | "Paint";
+const LIMIT = 400;
+const samples: Record> = { Update: [], View: [], Patch: [], Paint: [] };
+
+const push = (phase: Phase, ms: number) => {
+ const list = samples[phase];
+ list.push(ms);
+ if (list.length > LIMIT) list.shift();
+};
+
+const defaultBudgets = { Update: 4, View: 16, Patch: 8, SubscriptionDependencies: 2 } as const;
+
+/** Measure every phase; keep Foldkit's default slow warnings in development. */
+export const slowConfig = (): Runtime.SlowConfig => ({
+ show: "Always",
+ measuredPhases: ["Update", "View", "Patch"],
+ thresholdOverrides: { Update: -1, View: -1, Patch: -1 },
+ onSlow: (context) => {
+ if (context._tag !== "SubscriptionDependencies") push(context._tag, context.durationMs);
+ if (import.meta.env.DEV && context.durationMs > defaultBudgets[context._tag])
+ console.warn(`[foldkit] Slow ${context._tag}: ${context.durationMs.toFixed(1)}ms`);
+ },
+});
+
+export const percentile = (values: ReadonlyArray, p: number): number => {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]!;
+};
+
+const format = (ms: number): string => (ms < 1 ? `${ms.toFixed(2)} ms` : `${ms.toFixed(1)} ms`);
+
+/** Records input-to-next-paint for interactions inside the observed element. */
+export const Readout = Mount.defineStream("MobileFormPerformanceReadout", {
+ args: { scope: S.String },
+ messages: [S.Never],
+ execute: ({ scope, element }) =>
+ Stream.callback(() =>
+ Effect.gen(function* () {
+ yield* Effect.acquireRelease(
+ Effect.sync(() => {
+ const onInteraction = (event: Event) => {
+ if (!(event.target instanceof Element) || !event.target.closest(scope)) return;
+ const start = event.timeStamp;
+ requestAnimationFrame(() =>
+ setTimeout(() => push("Paint", performance.now() - start), 0),
+ );
+ };
+ const render = () => {
+ for (const phase of ["Update", "View", "Patch", "Paint"] as const) {
+ const cell = element.querySelector(`[data-phase="${phase}"]`);
+ if (cell === null) continue;
+ const values = samples[phase];
+ cell.textContent =
+ values.length === 0
+ ? "–"
+ : `${format(percentile(values, 50))} · ${format(percentile(values, 95))}`;
+ }
+ const count = element.querySelector("[data-count]");
+ if (count !== null) count.textContent = String(samples.Paint.length);
+ };
+ document.addEventListener("input", onInteraction, true);
+ document.addEventListener("click", onInteraction, true);
+ const timer = setInterval(render, 500);
+ return () => {
+ document.removeEventListener("input", onInteraction, true);
+ document.removeEventListener("click", onInteraction, true);
+ clearInterval(timer);
+ };
+ }),
+ (dispose) => Effect.sync(dispose),
+ );
+ return yield* Effect.never;
+ }),
+ ),
+});
+
+/** Exposes raw samples so the benchmark script can read the same numbers. */
+export const exposeSamples = () => {
+ (globalThis as { __foldworksMobileFormSamples?: typeof samples }).__foldworksMobileFormSamples =
+ samples;
+};
diff --git a/apps/demo/src/mobile-form/standalone.css b/apps/demo/src/mobile-form/standalone.css
new file mode 100644
index 0000000..11355f1
--- /dev/null
+++ b/apps/demo/src/mobile-form/standalone.css
@@ -0,0 +1,21 @@
+@import "@foldworks/ui/base.css";
+@import "@foldworks/ui/themes/shadcn.css";
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ height: 100%;
+ overscroll-behavior: none;
+}
+
+body {
+ background: var(--background);
+ color: var(--foreground);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
diff --git a/apps/demo/src/mobile-form/standalone.ts b/apps/demo/src/mobile-form/standalone.ts
new file mode 100644
index 0000000..7a70730
--- /dev/null
+++ b/apps/demo/src/mobile-form/standalone.ts
@@ -0,0 +1,27 @@
+import { Runtime } from "foldkit";
+import { Message } from "./message";
+import { init, Model } from "./model";
+import { exposeSamples, slowConfig } from "./performance";
+import { update } from "./update";
+import { formApp } from "./form-view";
+import { className, styles } from "./styles";
+
+/**
+ * The candidate form as its own application, without the demo shell. This is
+ * the build the payload and interaction benchmarks measure.
+ */
+exposeSamples();
+
+const application = Runtime.makeApplication({
+ Model,
+ init: () => update(init(), Message.Initialize()),
+ update,
+ view: (model, h) => ({
+ title: "Apply · Foldworks",
+ body: h.main([h.Class(className(styles.standalone))], [formApp(model, h)]),
+ }),
+ container: document.getElementById("root"),
+ slow: slowConfig(),
+});
+
+Runtime.run(application);
diff --git a/apps/demo/src/mobile-form/states.ts b/apps/demo/src/mobile-form/states.ts
new file mode 100644
index 0000000..1cbb3e9
--- /dev/null
+++ b/apps/demo/src/mobile-form/states.ts
@@ -0,0 +1,73 @@
+export type UsState = Readonly<{ code: string; name: string }>;
+
+export const usStates: ReadonlyArray = [
+ ["AL", "Alabama"],
+ ["AK", "Alaska"],
+ ["AZ", "Arizona"],
+ ["AR", "Arkansas"],
+ ["CA", "California"],
+ ["CO", "Colorado"],
+ ["CT", "Connecticut"],
+ ["DE", "Delaware"],
+ ["DC", "District of Columbia"],
+ ["FL", "Florida"],
+ ["GA", "Georgia"],
+ ["HI", "Hawaii"],
+ ["ID", "Idaho"],
+ ["IL", "Illinois"],
+ ["IN", "Indiana"],
+ ["IA", "Iowa"],
+ ["KS", "Kansas"],
+ ["KY", "Kentucky"],
+ ["LA", "Louisiana"],
+ ["ME", "Maine"],
+ ["MD", "Maryland"],
+ ["MA", "Massachusetts"],
+ ["MI", "Michigan"],
+ ["MN", "Minnesota"],
+ ["MS", "Mississippi"],
+ ["MO", "Missouri"],
+ ["MT", "Montana"],
+ ["NE", "Nebraska"],
+ ["NV", "Nevada"],
+ ["NH", "New Hampshire"],
+ ["NJ", "New Jersey"],
+ ["NM", "New Mexico"],
+ ["NY", "New York"],
+ ["NC", "North Carolina"],
+ ["ND", "North Dakota"],
+ ["OH", "Ohio"],
+ ["OK", "Oklahoma"],
+ ["OR", "Oregon"],
+ ["PA", "Pennsylvania"],
+ ["RI", "Rhode Island"],
+ ["SC", "South Carolina"],
+ ["SD", "South Dakota"],
+ ["TN", "Tennessee"],
+ ["TX", "Texas"],
+ ["UT", "Utah"],
+ ["VT", "Vermont"],
+ ["VA", "Virginia"],
+ ["WA", "Washington"],
+ ["WV", "West Virginia"],
+ ["WI", "Wisconsin"],
+ ["WY", "Wyoming"],
+].map(([code, name]) => ({ code: code!, name: name! }));
+
+/** Matches a state by name prefix, any word prefix, or exact postal code. */
+export const filterStates = (query: string): ReadonlyArray => {
+ const q = query.trim().toLowerCase();
+ if (q === "") return usStates;
+ return usStates.filter(
+ (state) =>
+ state.code.toLowerCase() === q ||
+ state.name.toLowerCase().startsWith(q) ||
+ state.name
+ .toLowerCase()
+ .split(" ")
+ .some((word) => word.startsWith(q)),
+ );
+};
+
+export const stateName = (code: string): string =>
+ usStates.find((state) => state.code === code)?.name ?? "";
diff --git a/apps/demo/src/mobile-form/styles.ts b/apps/demo/src/mobile-form/styles.ts
new file mode 100644
index 0000000..0a77564
--- /dev/null
+++ b/apps/demo/src/mobile-form/styles.ts
@@ -0,0 +1,295 @@
+import * as stylex from "@stylexjs/stylex";
+
+const phone = "@media (max-width: 720px)";
+const motion = "@media (prefers-reduced-motion: no-preference)";
+const press = "@media (hover: none)";
+
+export const className = (...sx: ReadonlyArray): string =>
+ stylex.props(...sx).className ?? "";
+
+export const styles = stylex.create({
+ page: {
+ display: "grid",
+ gridTemplateColumns: { default: "auto minmax(0, 440px)", [phone]: "minmax(0, 1fr)" },
+ justifyContent: "center",
+ alignItems: "start",
+ gap: 48,
+ height: "100%",
+ minHeight: 0,
+ overflow: { default: "auto", [phone]: "hidden" },
+ padding: { default: "32px 24px", [phone]: 0 },
+ },
+ device: {
+ width: { default: 390, [phone]: "100%" },
+ height: { default: 780, [phone]: "100%" },
+ borderRadius: { default: 44, [phone]: 0 },
+ borderWidth: { default: 10, [phone]: 0 },
+ borderStyle: "solid",
+ borderColor: "#111",
+ boxShadow: { default: "0 30px 60px -20px rgb(0 0 0 / 35%)", [phone]: "none" },
+ overflow: "hidden",
+ // Contain fixed and sticky descendants so the desktop frame behaves like a viewport.
+ transform: { default: "translateZ(0)", [phone]: "none" },
+ backgroundColor: "var(--background)",
+ },
+ standalone: { height: "100dvh" },
+ app: {
+ position: "relative",
+ display: "flex",
+ flexDirection: "column",
+ height: "100%",
+ color: "var(--foreground)",
+ backgroundColor: "var(--background)",
+ fontFamily: "var(--font-sans)",
+ WebkitTextSizeAdjust: "100%",
+ },
+ top: {
+ display: "grid",
+ gridTemplateColumns: "44px minmax(0, 1fr) 44px",
+ alignItems: "center",
+ gap: 8,
+ paddingTop: "max(8px, env(safe-area-inset-top))",
+ paddingBottom: 8,
+ paddingInline: 8,
+ borderBottom: "1px solid var(--border)",
+ backgroundColor: "var(--background)",
+ },
+ iconButton: {
+ display: "grid",
+ placeItems: "center",
+ width: 44,
+ height: 44,
+ borderWidth: 0,
+ borderRadius: 999,
+ backgroundColor: { default: "transparent", ":active": "var(--muted)" },
+ color: "var(--foreground)",
+ cursor: "pointer",
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ },
+ progress: { display: "flex", flexDirection: "column", alignItems: "center", gap: 6 },
+ progressLabel: { fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" },
+ progressTrack: {
+ width: "100%",
+ maxWidth: 180,
+ height: 4,
+ borderRadius: 999,
+ backgroundColor: "var(--muted)",
+ overflow: "hidden",
+ },
+ progressFill: {
+ height: "100%",
+ borderRadius: 999,
+ backgroundColor: "var(--primary)",
+ transformOrigin: "left",
+ transition: { default: null, [motion]: "transform 240ms cubic-bezier(.2,.8,.2,1)" },
+ },
+ scroll: {
+ flexGrow: 1,
+ minHeight: 0,
+ overflowY: "auto",
+ overscrollBehavior: "contain",
+ WebkitOverflowScrolling: "touch",
+ },
+ form: {
+ display: "flex",
+ flexDirection: "column",
+ minHeight: "100%",
+ },
+ body: {
+ display: "flex",
+ flexDirection: "column",
+ gap: 22,
+ flexGrow: 1,
+ paddingTop: 24,
+ paddingBottom: 32,
+ paddingInline: 20,
+ },
+ heading: {
+ margin: 0,
+ fontSize: 28,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ lineHeight: 1.15,
+ outline: "none",
+ scrollMarginTop: 24,
+ },
+ lede: {
+ margin: 0,
+ marginTop: -12,
+ fontSize: 16,
+ color: "var(--muted-foreground)",
+ lineHeight: 1.45,
+ },
+ pair: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 },
+ actions: {
+ position: "sticky",
+ bottom: "var(--keyboard-inset, 0px)",
+ display: "flex",
+ flexDirection: "column",
+ gap: 8,
+ paddingTop: 12,
+ paddingInline: 20,
+ paddingBottom: "max(12px, env(safe-area-inset-bottom))",
+ borderTop: "1px solid var(--border)",
+ backgroundColor: "color-mix(in oklch, var(--background) 92%, transparent)",
+ backdropFilter: "blur(12px)",
+ },
+ primary: {
+ minHeight: 54,
+ borderWidth: 0,
+ borderRadius: 14,
+ backgroundColor: "var(--primary)",
+ color: "var(--primary-foreground)",
+ fontSize: 17,
+ fontWeight: 600,
+ fontFamily: "inherit",
+ cursor: "pointer",
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ transform: { default: null, [press]: { default: null, ":active": "scale(0.98)" } },
+ transition: { default: null, [motion]: "transform 90ms, opacity 90ms" },
+ opacity: { default: 1, ":active": 0.9 },
+ },
+ secondary: {
+ minHeight: 44,
+ borderWidth: 0,
+ backgroundColor: "transparent",
+ color: "var(--foreground)",
+ fontSize: 16,
+ fontWeight: 500,
+ fontFamily: "inherit",
+ textDecoration: "underline",
+ textUnderlineOffset: 3,
+ cursor: "pointer",
+ touchAction: "manipulation",
+ },
+ inlineLink: {
+ padding: 0,
+ borderWidth: 0,
+ backgroundColor: "transparent",
+ color: "var(--primary)",
+ font: "inherit",
+ fontWeight: 600,
+ textDecoration: "underline",
+ textUnderlineOffset: 3,
+ cursor: "pointer",
+ },
+ done: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ textAlign: "center",
+ gap: 16,
+ paddingTop: 48,
+ paddingInline: 24,
+ },
+ doneBadge: {
+ display: "grid",
+ placeItems: "center",
+ width: 72,
+ height: 72,
+ borderRadius: 999,
+ backgroundColor: "var(--primary)",
+ color: "var(--primary-foreground)",
+ },
+ summary: {
+ width: "100%",
+ margin: 0,
+ marginTop: 8,
+ padding: 0,
+ display: "grid",
+ gridTemplateColumns: "auto minmax(0, 1fr)",
+ gap: "10px 16px",
+ textAlign: "left",
+ fontSize: 15,
+ },
+ summaryTerm: { color: "var(--muted-foreground)" },
+ summaryValue: { margin: 0, fontWeight: 500, overflowWrap: "anywhere" },
+ sheetSearch: { paddingBottom: 8 },
+ searchControl: {
+ minHeight: 48,
+ paddingLeft: 42,
+ borderColor: { default: "transparent", ":focus": "transparent" },
+ borderRadius: 999,
+ backgroundColor: "var(--muted)",
+ boxShadow: { default: "none", ":focus": "0 0 0 2px var(--ring)" },
+ "::-webkit-search-cancel-button": { appearance: "none" },
+ },
+ searchIcon: {
+ position: "absolute",
+ left: 14,
+ top: "50%",
+ transform: "translateY(-50%)",
+ color: "var(--muted-foreground)",
+ pointerEvents: "none",
+ },
+ sheetList: {
+ listStyle: "none",
+ margin: 0,
+ padding: 0,
+ overflowY: "auto",
+ overscrollBehavior: "contain",
+ flexGrow: 1,
+ minHeight: 0,
+ },
+ sheetRow: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ width: "100%",
+ minHeight: 52,
+ paddingInline: 4,
+ borderWidth: 0,
+ borderBottom: "1px solid var(--border)",
+ backgroundColor: { default: "transparent", ":active": "var(--muted)" },
+ color: "var(--foreground)",
+ fontSize: 17,
+ fontFamily: "inherit",
+ textAlign: "left",
+ cursor: "pointer",
+ touchAction: "manipulation",
+ WebkitTapHighlightColor: "transparent",
+ },
+ sheetCode: { color: "var(--muted-foreground)", fontSize: 15, marginLeft: 8 },
+ sheetEmpty: { padding: 16, color: "var(--muted-foreground)", textAlign: "center" },
+ srOnly: {
+ position: "absolute",
+ width: 1,
+ height: 1,
+ overflow: "hidden",
+ clipPath: "inset(50%)",
+ whiteSpace: "nowrap",
+ },
+ aside: {
+ display: { default: "flex", [phone]: "none" },
+ flexDirection: "column",
+ gap: 20,
+ paddingTop: 12,
+ fontSize: 14,
+ lineHeight: 1.55,
+ },
+ asideTitle: { margin: 0, fontSize: 24, fontWeight: 650, letterSpacing: "-0.02em" },
+ asideText: { margin: 0, color: "var(--muted-foreground)" },
+ list: { margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 },
+ readout: {
+ display: "grid",
+ gridTemplateColumns: "auto 1fr",
+ gap: "6px 16px",
+ padding: 16,
+ borderRadius: 12,
+ border: "1px solid var(--border)",
+ backgroundColor: "var(--card)",
+ fontVariantNumeric: "tabular-nums",
+ },
+ readoutTitle: {
+ gridColumn: "1 / -1",
+ fontWeight: 600,
+ display: "flex",
+ justifyContent: "space-between",
+ },
+ readoutLabel: { color: "var(--muted-foreground)" },
+ readoutValue: { textAlign: "right", fontFamily: "var(--font-mono, ui-monospace, monospace)" },
+ readoutNote: { gridColumn: "1 / -1", fontSize: 12, color: "var(--muted-foreground)" },
+ link: { color: "var(--primary)", fontWeight: 600 },
+});
diff --git a/apps/demo/src/mobile-form/update.test.ts b/apps/demo/src/mobile-form/update.test.ts
new file mode 100644
index 0000000..27f688e
--- /dev/null
+++ b/apps/demo/src/mobile-form/update.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from "vitest";
+
+import { Message } from "./message";
+import { init, type Model } from "./model";
+import { update } from "./update";
+
+const ready = (): Model => ({ ...init(), today: "2026-10-20" });
+const apply = (model: Model, ...messages: ReadonlyArray): Model =>
+ messages.reduce((current, message) => update(current, message).model, model);
+const commandNames = (result: ReturnType) =>
+ (result.commands ?? []).map(({ name }) => name);
+
+const aboutYou = [
+ Message.ChangedText({ field: "first-name", value: "Ada" }),
+ Message.ChangedText({ field: "last-name", value: "Lovelace" }),
+ Message.ChangedText({ field: "email", value: "ada@example.com" }),
+ Message.ChangedText({ field: "phone", value: "+1 555 123 4567" }),
+ Message.ChangedText({ field: "dob", value: "07041990" }),
+];
+
+describe("mobile candidate form", () => {
+ it("normalizes masked fields in the model, whatever the input path", () => {
+ const model = apply(ready(), ...aboutYou);
+
+ expect(model.values.text.phone).toBe("(555) 123-4567");
+ expect(model.values.text.dob).toBe("07/04/1990");
+ });
+
+ it("keeps keystrokes in memory and saves the draft when a field is left", () => {
+ const typed = update(ready(), Message.ChangedText({ field: "first-name", value: "A" }));
+ const left = update(typed.model, Message.LeftField({ field: "first-name" }));
+
+ expect(commandNames(typed)).toEqual([]);
+ expect(commandNames(left)).toEqual(["PersistMobileForm"]);
+ expect(left.model.visited).toEqual(["first-name"]);
+ });
+
+ it("reveals every error on submit and focuses the first invalid field", () => {
+ const result = update(
+ apply(ready(), Message.ChangedText({ field: "first-name", value: "Ada" })),
+ Message.Submitted(),
+ );
+
+ expect(result.model.step).toBe("About");
+ expect(result.model.visited).toEqual(["first-name", "last-name", "email", "phone", "dob"]);
+ expect(result.model.announcement).toBe("4 fields need attention.");
+ expect(commandNames(result)).toEqual(["FocusMobileFormField"]);
+ });
+
+ it("rejects candidates younger than sixteen", () => {
+ const model = apply(
+ ready(),
+ ...aboutYou,
+ Message.ChangedText({ field: "dob", value: "01012015" }),
+ Message.Submitted(),
+ );
+
+ expect(model.step).toBe("About");
+ });
+
+ it("walks every step and submits a complete one-time code", () => {
+ const verify = apply(
+ ready(),
+ ...aboutYou,
+ Message.Submitted(),
+ Message.ChangedText({ field: "street", value: "1 Main Street" }),
+ Message.ChangedText({ field: "city", value: "Oakland" }),
+ Message.AutofilledState({ code: "CA" }),
+ Message.ChangedText({ field: "zip", value: "94612-1234" }),
+ Message.Submitted(),
+ Message.SelectedEmployment({ value: "part-time" }),
+ Message.SteppedHours({ delta: 50 }),
+ Message.ChangedText({ field: "start-date", value: "2026-11-02" }),
+ Message.ToggledShift({ shift: "evenings" }),
+ Message.Submitted(),
+ Message.Answered({ question: "license", answer: "no" }),
+ Message.Answered({ question: "rehire", answer: "no" }),
+ Message.Submitted(),
+ );
+
+ expect(verify.step).toBe("Verify");
+ expect(verify.values.hours).toBe(60);
+ expect(verify.values.text.zip).toBe("94612");
+
+ const done = update(verify, Message.ChangedText({ field: "otp", value: "12a3456" }));
+ expect(done.model.values.text.otp).toBe("123456");
+ expect(done.model.step).toBe("Done");
+ });
+
+ it("validates follow-up questions only while their answer reveals them", () => {
+ const background: Model = { ...ready(), step: "Background" };
+ const unanswered = update(background, Message.Submitted());
+ expect(unanswered.model.step).toBe("Background");
+ expect(unanswered.model.announcement).toBe("2 fields need attention.");
+
+ const yes = apply(
+ background,
+ Message.Answered({ question: "license", answer: "yes" }),
+ Message.Answered({ question: "rehire", answer: "no" }),
+ Message.Answered({ question: "cdl", answer: "yes" }),
+ Message.Submitted(),
+ );
+ // The license number and the nested CDL class are now required.
+ expect(yes.step).toBe("Background");
+ expect(yes.announcement).toBe("2 fields need attention.");
+
+ const typed = apply(yes, Message.ChangedText({ field: "license-number", value: "D1234567" }));
+ const no = apply(typed, Message.Answered({ question: "license", answer: "no" }));
+ // Hidden fields keep their values but no longer block the step.
+ expect(no.values.text["license-number"]).toBe("D1234567");
+ expect(update(no, Message.Submitted()).model.step).toBe("Verify");
+ // Answering yes again restores the previous answers, which still need a class.
+ const again = apply(no, Message.Answered({ question: "license", answer: "yes" }));
+ expect(again.values.text["license-number"]).toBe("D1234567");
+ expect(update(again, Message.Submitted()).model.step).toBe("Background");
+ });
+
+ it("requires a past month for a previous job", () => {
+ const answered = apply(
+ { ...ready(), step: "Background" },
+ Message.Answered({ question: "license", answer: "no" }),
+ Message.Answered({ question: "rehire", answer: "yes" }),
+ Message.ChangedText({ field: "previous-location", value: "Oakland" }),
+ Message.ChangedText({ field: "previous-end", value: "122026" }),
+ );
+
+ expect(answered.values.text["previous-end"]).toBe("12/2026");
+ expect(update(answered, Message.Submitted()).model.step).toBe("Background");
+ const past = apply(answered, Message.ChangedText({ field: "previous-end", value: "062022" }));
+ expect(update(past, Message.Submitted()).model.step).toBe("Verify");
+ });
+
+ it("rejects start dates in the past", () => {
+ const model = apply(
+ { ...ready(), step: "Work" },
+ Message.SelectedEmployment({ value: "seasonal" }),
+ Message.ChangedText({ field: "start-date", value: "2026-10-19" }),
+ Message.ToggledShift({ shift: "weekends" }),
+ Message.Submitted(),
+ );
+
+ expect(model.step).toBe("Work");
+ expect(model.visited).toContain("start-date");
+ });
+
+ it("restores a saved draft and the current date", () => {
+ const draft = {
+ version: 2 as const,
+ step: "Address" as const,
+ values: apply(ready(), ...aboutYou).values,
+ };
+ const model = update(init(), Message.Restored({ today: "2026-10-20", draft })).model;
+
+ expect(model.step).toBe("Address");
+ expect(model.values.text["first-name"]).toBe("Ada");
+ expect(model.today).toBe("2026-10-20");
+ });
+});
diff --git a/apps/demo/src/mobile-form/update.ts b/apps/demo/src/mobile-form/update.ts
new file mode 100644
index 0000000..30ecb51
--- /dev/null
+++ b/apps/demo/src/mobile-form/update.ts
@@ -0,0 +1,257 @@
+import { Effect, Option, Schema as S } from "effect";
+import { Command, Dom, Update } from "foldkit";
+import * as Dialog from "@foldkit/ui/dialog";
+import { formatMask, type MaskKind } from "./format";
+import { Message } from "./message";
+import {
+ Draft,
+ emptyValues,
+ formSteps,
+ type FieldId,
+ type Model,
+ type Step,
+ type TextFieldId,
+ type Values,
+} from "./model";
+import { invalidFields, stepFields } from "./validation";
+
+type UpdateReturn = Update.Return;
+
+export const STORAGE_KEY = "foldworks-mobile-form";
+
+const masks: Partial> = {
+ phone: "Phone",
+ dob: "Date",
+ "previous-end": "Month",
+ zip: "Zip",
+ otp: "Otp",
+};
+
+/** The DOM id that receives focus for a field, including grouped controls. */
+export const focusTarget = (field: FieldId, values: Values): string => {
+ switch (field) {
+ case "employment":
+ return `employment-${values.employment || "full-time"}`;
+ case "shifts":
+ return `shift-${values.shifts[0] ?? "mornings"}`;
+ case "license":
+ case "cdl":
+ case "rehire":
+ return `${field}-${values.answers[field] || "yes"}`;
+ case "cdl-class":
+ return `cdl-class-${values.cdlClass || "a"}`;
+ default:
+ return field;
+ }
+};
+
+const Restore = Command.define("RestoreMobileForm", {
+ messages: [Message.Restored],
+ execute: Effect.sync(() => {
+ const now = new Date();
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+ try {
+ const json = localStorage.getItem(STORAGE_KEY);
+ return Message.Restored({
+ today,
+ draft: json === null ? null : S.decodeUnknownSync(Draft)(JSON.parse(json)),
+ });
+ } catch {
+ return Message.Restored({ today, draft: null });
+ }
+ }),
+});
+
+const Persist = Command.define("PersistMobileForm", {
+ args: { draft: Draft },
+ messages: [Message.CompletedPersist],
+ execute: ({ draft }) =>
+ Effect.sync(() => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
+ } catch {
+ /* Private browsing: the form still works for this visit. */
+ }
+ return Message.CompletedPersist();
+ }),
+});
+
+const Focus = Command.define("FocusMobileFormField", {
+ args: { id: S.String },
+ messages: [Message.CompletedFocus],
+ execute: ({ id }) =>
+ Dom.focus(`#${CSS.escape(id)}`, { preventScroll: true }).pipe(
+ Effect.andThen(Dom.scrollIntoView(`#${CSS.escape(id)}`, { block: "center" })),
+ Effect.ignore,
+ Effect.as(Message.CompletedFocus()),
+ ),
+});
+
+/** Moves to a step and puts focus on its heading so screen readers announce it. */
+const ShowStep = Command.define("ShowMobileFormStep", {
+ messages: [Message.CompletedFocus],
+ execute: Dom.focus("#mobile-form-heading", { preventScroll: true, makeFocusable: true }).pipe(
+ Effect.andThen(
+ Effect.sync(() => document.querySelector("#mobile-form-scroll")?.scrollTo(0, 0)),
+ ),
+ Effect.ignore,
+ Effect.as(Message.CompletedFocus()),
+ ),
+});
+
+const persist = (model: Model, commands: UpdateReturn["commands"] = []): UpdateReturn => ({
+ model,
+ commands: [
+ ...(commands ?? []),
+ Persist({ draft: { version: 2, step: model.step, values: model.values } }),
+ ],
+});
+
+const withValues = (model: Model, values: Partial): Model => ({
+ ...model,
+ values: { ...model.values, ...values },
+});
+
+const visit = (model: Model, fields: ReadonlyArray): Model => ({
+ ...model,
+ visited: [...new Set([...model.visited, ...fields])],
+});
+
+const stepTitles: Readonly> = {
+ About: "About you",
+ Address: "Where do you live?",
+ Work: "Your availability",
+ Background: "A few more questions",
+ Verify: "Verify your phone",
+ Done: "You're all set",
+};
+export const stepTitle = (step: Step): string => stepTitles[step];
+
+const goTo = (model: Model, step: Step): UpdateReturn => {
+ const index = formSteps.indexOf(step as (typeof formSteps)[number]);
+ return persist(
+ {
+ ...model,
+ step,
+ announcement:
+ index === -1
+ ? stepTitle(step)
+ : `Step ${index + 1} of ${formSteps.length}: ${stepTitle(step)}`,
+ },
+ // The code field takes focus so Android opens the keypad and SMS autofill is one tap away.
+ [step === "Verify" ? Focus({ id: "otp" }) : ShowStep()],
+ );
+};
+
+const submit = (model: Model): UpdateReturn => {
+ const invalid = invalidFields(model.step, model.values, model.today);
+ const first = invalid[0];
+ if (first !== undefined) {
+ return {
+ model: {
+ ...visit(model, stepFields[model.step]),
+ announcement: `${invalid.length} ${invalid.length === 1 ? "field needs" : "fields need"} attention.`,
+ },
+ commands: [Focus({ id: focusTarget(first, model.values) })],
+ };
+ }
+ const next = formSteps[formSteps.indexOf(model.step as (typeof formSteps)[number]) + 1];
+ return goTo(model, next ?? "Done");
+};
+
+const foldStateSheet = Update.foldChild({
+ update: Dialog.update,
+ read: (model: Model) => Option.some(model.stateSheet),
+ write: (model, stateSheet) => ({ ...model, stateSheet }),
+ toParentMessage: (message) => Message.GotStateSheetMessage({ message }),
+ foldOutMessage: (_message: Dialog.OutMessage) => (model: Model) => ({ model }),
+});
+
+export const update = (model: Model, message: Message): UpdateReturn =>
+ Message.match(message, {
+ Initialize: () => ({ model, commands: [Restore()] }),
+ Restored: ({ today, draft }) =>
+ draft === null
+ ? { model: { ...model, today } }
+ : { model: { ...model, today, step: draft.step, values: draft.values } },
+ ChangedText: ({ field, value }) => {
+ const mask = masks[field];
+ const next = withValues(model, {
+ text: {
+ ...model.values.text,
+ [field]: mask === undefined ? value : formatMask(mask, value),
+ },
+ });
+ // One-time codes submit themselves once complete, matching SMS autofill.
+ // Keystrokes stay in memory; the draft is written when the field is left.
+ return field === "otp" && next.values.text.otp.length === 6 && model.step === "Verify"
+ ? submit(next)
+ : { model: next };
+ },
+ // Leaving a field saves the draft. Switching apps blurs the field, so a candidate
+ // who opens Messages for the code keeps everything they typed.
+ LeftField: ({ field }) => persist(visit(model, [field])),
+ AcceptedEmailFix: ({ email }) =>
+ persist(withValues(model, { text: { ...model.values.text, email } }), [
+ Focus({ id: "email" }),
+ ]),
+ SelectedEmployment: ({ value }) =>
+ persist(visit(withValues(model, { employment: value }), ["employment"])),
+ ChangedHours: ({ value }) => {
+ const hours = Number(value.replace(/\D/g, "").slice(0, 2));
+ return persist(withValues(model, { hours: Number.isFinite(hours) ? hours : 0 }));
+ },
+ SteppedHours: ({ delta }) =>
+ persist(withValues(model, { hours: Math.min(60, Math.max(1, model.values.hours + delta)) })),
+ ToggledShift: ({ shift }) =>
+ persist(
+ visit(
+ withValues(model, {
+ shifts: model.values.shifts.includes(shift)
+ ? model.values.shifts.filter((value) => value !== shift)
+ : [...model.values.shifts, shift],
+ }),
+ ["shifts"],
+ ),
+ ),
+ ToggledAlerts: ({ value }) => persist(withValues(model, { alerts: value })),
+ Answered: ({ question, answer }) =>
+ persist(
+ visit(withValues(model, { answers: { ...model.values.answers, [question]: answer } }), [
+ question,
+ ]),
+ ),
+ SelectedCdlClass: ({ value }) =>
+ persist(visit(withValues(model, { cdlClass: value }), ["cdl-class"])),
+ OpenedStateSheet: () =>
+ foldStateSheet({ ...model, stateQuery: "" }, Dialog.Message.RequestedOpen()),
+ ChangedStateQuery: ({ value }) => ({ model: { ...model, stateQuery: value } }),
+ ChosenState: ({ code }) => {
+ const closed = foldStateSheet(
+ visit(withValues(model, { state: code }), ["state"]),
+ Dialog.Message.RequestedClose(),
+ );
+ return persist(closed.model, closed.commands);
+ },
+ AutofilledState: ({ code }) => persist(withValues(model, { state: code })),
+ GotStateSheetMessage: ({ message: child }) => foldStateSheet(model, child),
+ Submitted: () => submit(model),
+ PressedBack: () => {
+ const index = formSteps.indexOf(model.step as (typeof formSteps)[number]);
+ return index > 0 ? goTo(model, formSteps[index - 1]!) : { model };
+ },
+ PressedEditPhone: () =>
+ persist(
+ { ...model, step: "About", announcement: `Step 1 of ${formSteps.length}: About you` },
+ [Focus({ id: "phone" })],
+ ),
+ PressedResend: () => ({
+ model: {
+ ...withValues(model, { text: { ...model.values.text, otp: "" } }),
+ announcement: "Code sent. It can take up to a minute to arrive.",
+ },
+ }),
+ PressedStartOver: () => goTo({ ...model, values: emptyValues, visited: [] }, "About"),
+ CompletedFocus: () => ({ model }),
+ CompletedPersist: () => ({ model }),
+ });
diff --git a/apps/demo/src/mobile-form/validation.ts b/apps/demo/src/mobile-form/validation.ts
new file mode 100644
index 0000000..6da2e59
--- /dev/null
+++ b/apps/demo/src/mobile-form/validation.ts
@@ -0,0 +1,128 @@
+import type { FieldId, Step, Values } from "./model";
+import { ageOn, emailSuggestion, isEmail, parseDate, parseMonth, phoneDigits } from "./format";
+
+/** Fields in visual order; Enter advances through them and errors focus the first. */
+export const stepFields: Readonly>> = {
+ About: ["first-name", "last-name", "email", "phone", "dob"],
+ Address: ["street", "apt", "city", "state", "zip"],
+ Work: ["employment", "hours", "start-date", "shifts"],
+ Background: [
+ "license",
+ "license-number",
+ "cdl",
+ "cdl-class",
+ "rehire",
+ "previous-location",
+ "previous-end",
+ ],
+ Verify: ["otp"],
+ Done: [],
+};
+
+/**
+ * Follow-up questions exist only while the answer above them is "yes". Hidden
+ * fields keep their values, so answering "yes" again restores them, but they
+ * are never validated or required.
+ */
+export const isShown = (field: FieldId, values: Values): boolean => {
+ switch (field) {
+ case "license-number":
+ case "cdl":
+ return values.answers.license === "yes";
+ case "cdl-class":
+ return values.answers.license === "yes" && values.answers.cdl === "yes";
+ case "previous-location":
+ case "previous-end":
+ return values.answers.rehire === "yes";
+ default:
+ return true;
+ }
+};
+
+const required = (value: string, message: string) => (value.trim() === "" ? message : undefined);
+
+export const fieldError = (field: FieldId, values: Values, today: string): string | undefined => {
+ const text = values.text;
+ if (!isShown(field, values)) return undefined;
+ switch (field) {
+ case "first-name":
+ return required(text["first-name"], "Enter your first name.");
+ case "last-name":
+ return required(text["last-name"], "Enter your last name.");
+ case "email":
+ return (
+ required(text.email, "Enter your email address.") ??
+ (isEmail(text.email.trim()) ? undefined : "Enter an email like name@example.com.")
+ );
+ case "phone":
+ return phoneDigits(text.phone).length === 10 ? undefined : "Enter a 10-digit mobile number.";
+ case "dob": {
+ const birth = parseDate(text.dob);
+ if (birth === undefined) return "Enter a real date as MM/DD/YYYY.";
+ const now = parseIso(today);
+ if (now === undefined) return undefined;
+ const age = ageOn(birth, now);
+ return age < 16
+ ? "You must be at least 16 to apply."
+ : age > 100
+ ? "Check the year you were born."
+ : undefined;
+ }
+ case "street":
+ return required(text.street, "Enter your street address.");
+ case "apt":
+ return undefined;
+ case "city":
+ return required(text.city, "Enter your city.");
+ case "state":
+ return values.state === "" ? "Choose your state." : undefined;
+ case "zip":
+ return /^\d{5}$/.test(text.zip) ? undefined : "Enter a 5-digit ZIP code.";
+ case "employment":
+ return values.employment === "" ? "Choose how you want to work." : undefined;
+ case "hours":
+ return values.hours >= 1 && values.hours <= 60 ? undefined : "Choose 1 to 60 hours.";
+ case "start-date":
+ return text["start-date"] === ""
+ ? "Choose your earliest start date."
+ : today !== "" && text["start-date"] < today
+ ? "Choose today or a later date."
+ : undefined;
+ case "shifts":
+ return values.shifts.length === 0 ? "Choose at least one shift." : undefined;
+ case "license":
+ case "cdl":
+ case "rehire":
+ return values.answers[field] === "" ? "Choose yes or no." : undefined;
+ case "license-number":
+ return required(text["license-number"], "Enter your license number.");
+ case "cdl-class":
+ return values.cdlClass === "" ? "Choose a CDL class." : undefined;
+ case "previous-location":
+ return required(text["previous-location"], "Enter the location.");
+ case "previous-end": {
+ const month = parseMonth(text["previous-end"]);
+ return month === undefined
+ ? "Enter a month as MM/YYYY."
+ : today !== "" && month > today.slice(0, 7)
+ ? "Choose a month that has passed."
+ : undefined;
+ }
+ case "otp":
+ return /^\d{6}$/.test(text.otp) ? undefined : "Enter the 6-digit code.";
+ }
+};
+
+export const invalidFields = (step: Step, values: Values, today: string): ReadonlyArray =>
+ stepFields[step].filter((field) => fieldError(field, values, today) !== undefined);
+
+/** Only offer a correction once the address otherwise looks complete. */
+export const emailFix = (email: string): string | undefined =>
+ isEmail(email.trim()) ? emailSuggestion(email.trim()) : undefined;
+
+const parseIso = (value: string) => {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
+ return match === null
+ ? undefined
+ : { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
+};
diff --git a/apps/demo/src/mobile-form/view.ts b/apps/demo/src/mobile-form/view.ts
new file mode 100644
index 0000000..c6fed65
--- /dev/null
+++ b/apps/demo/src/mobile-form/view.ts
@@ -0,0 +1,78 @@
+import type { Html, HtmlBuilder } from "foldkit/html";
+import { defineView } from "foldkit/submodel";
+import { formApp } from "./form-view";
+import type { Message } from "./message";
+import type { Model } from "./model";
+import { Readout } from "./performance";
+import { className, styles } from "./styles";
+
+type H = HtmlBuilder;
+
+const readoutRow = (label: string, phase: string, h: H): ReadonlyArray => [
+ h.span([h.Class(className(styles.readoutLabel))], [label]),
+ h.span([h.Class(className(styles.readoutValue)), h.DataAttribute("phase", phase)], ["–"]),
+];
+
+const aside = (h: H): Html =>
+ h.aside(
+ [h.Class(className(styles.aside))],
+ [
+ h.h2([h.Class(className(styles.asideTitle))], ["Mobile inputs"]),
+ h.p(
+ [h.Class(className(styles.asideText))],
+ [
+ "A candidate application built from stateless Foldkit controls tuned for phones. Each control is one view call with its own Messages; only the state picker uses a Submodel.",
+ ],
+ ),
+ h.ul(
+ [h.Class(className(styles.list))],
+ [
+ "17px text and 52px+ targets, so Safari never zooms and thumbs never miss.",
+ "The right keyboard per field, Enter moves to the next field, and the last one submits.",
+ "Autofill tokens on every field, including a hidden select behind the state sheet.",
+ "Phone, date, and ZIP masks that keep the caret in place and accept autofilled values.",
+ "One real one-time-code input drawn as six cells, so SMS autofill and paste work.",
+ "Errors appear after you leave a field, then clear as soon as it's fixed.",
+ "Yes/no questions reveal nested follow-ups with an animated expand. Hidden answers are kept but never validated.",
+ "The action bar rides above the keyboard and respects the home indicator.",
+ "Drafts survive a reload, such as switching to Messages for the code.",
+ ].map((item) => h.li([], [item])),
+ ),
+ h.div(
+ [h.Class(className(styles.readout)), h.OnMount(Readout({ scope: "#mobile-form-device" }))],
+ [
+ h.div(
+ [h.Class(className(styles.readoutTitle))],
+ [
+ h.span([], ["Live timings (p50 · p95)"]),
+ h.span(
+ [h.Class(className(styles.readoutLabel))],
+ [h.span([h.DataAttribute("count", "")], ["0"]), " samples"],
+ ),
+ ],
+ ),
+ ...readoutRow("Update", "Update", h),
+ ...readoutRow("View", "View", h),
+ ...readoutRow("Patch", "Patch", h),
+ ...readoutRow("Input → next paint", "Paint", h),
+ h.p(
+ [h.Class(className(styles.readoutNote))],
+ [
+ "Measured in this tab for the whole demo application, including every other demo's state. Type in the form to collect samples.",
+ ],
+ ),
+ ],
+ ),
+ h.a([h.Href("/apply.html"), h.Class(className(styles.link))], ["Open the standalone form →"]),
+ ],
+ );
+
+export const view = defineView((model, h) =>
+ h.div(
+ [h.Class(className(styles.page))],
+ [
+ h.div([h.Id("mobile-form-device"), h.Class(className(styles.device))], [formApp(model, h)]),
+ aside(h),
+ ],
+ ),
+);
diff --git a/apps/demo/vite.mobile-form.config.ts b/apps/demo/vite.mobile-form.config.ts
new file mode 100644
index 0000000..c441848
--- /dev/null
+++ b/apps/demo/vite.mobile-form.config.ts
@@ -0,0 +1,21 @@
+import { foldkit } from "@foldkit/vite-plugin";
+import { foldworksLayers } from "@foldworks/ui/vite";
+import stylex from "@stylexjs/unplugin";
+import { defineConfig } from "vite";
+
+/** Builds the mobile candidate form alone so its payload is measured without the demo shell. */
+export default defineConfig(({ mode }) => ({
+ plugins: [
+ stylex.vite({
+ dev: mode === "development",
+ runtimeInjection: false,
+ useCSSLayers: { before: foldworksLayers },
+ }),
+ foldkit(),
+ ],
+ build: {
+ emptyOutDir: false,
+ assetsDir: "apply",
+ rollupOptions: { input: "apply.html" },
+ },
+}));
diff --git a/docs/mobile-inputs.md b/docs/mobile-inputs.md
new file mode 100644
index 0000000..89ad421
--- /dev/null
+++ b/docs/mobile-inputs.md
@@ -0,0 +1,121 @@
+# Mobile inputs
+
+A candidate application form built to feel native on a phone, and a benchmark
+that compares it with the same form written in React. The demo lives at
+`/mobile-form`. A standalone build of only the form is at `/apply.html`.
+
+## What the form does
+
+The form has five steps: about you, address, availability, a few more
+questions, and phone verification. It uses stateless controls from `apps/demo/src/mobile-form/inputs.ts`. Each
+control is a single view function that takes callbacks for its Messages, so a
+parent adds a field with one call and owns the value. Only the state picker
+uses a Submodel, which wraps the headless Foldkit Dialog.
+
+| Concern | Behavior |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Zoom and targets | Inputs use 17px text, so iOS Safari never zooms on focus. Controls are at least 52px tall and chips at least 44px. Taps give press feedback through `:active` on touch devices only. |
+| Keyboards | Each field sets `inputmode`, `type`, `autocapitalize`, and `spellcheck` to suit its content. Email turns off autocorrect, and numeric fields open the keypad. |
+| Enter key | `enterkeyhint="next"` moves focus synchronously inside the key event, so the iOS keyboard stays open. The last field of a step shows "done" and submits through the form. |
+| Autofill | Every field has an `autocomplete` token. The state sheet keeps a hidden, focusable `