Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fi-file-fields-phase-a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jayoncode/form-intelligence": minor
---

File fields: Phase A foundation (`File[]` DOM read/clear, non-persistent omit from drafts/history/offline), Phase B ergonomics (`accept` / `maxSize` / `minSize` / `maxFiles` / `minFiles`, `form.toFormData()` / `form.payload()`, file-shaped `bind()`), and Phase C opt-in `@jayoncode/form-intelligence/upload` (`uploadTransport` multipart progress + abort). Raise `core-login` entry-chunk gzip budget 27→28 KB (ADR-013) for file orchestration on the createForm graph; upload XHR remains `/upload`-only.
2 changes: 1 addition & 1 deletion apps/browser-session-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@jayoncode/browser-lifecycle": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/docs/.vitepress/form-intelligence-sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function createFormIntelligenceSidebar(
{ text: "Validation", link: `${base}/modules/validation` },
{ text: "Submission", link: `${base}/modules/submission` },
{ text: "CAPTCHA", link: `${base}/modules/captcha` },
{ text: "Upload transport", link: `${base}/modules/upload` },
{ text: "State", link: `${base}/modules/state` },
{ text: "Workflow", link: `${base}/modules/workflow` },
{ text: "Rules", link: `${base}/modules/rules` },
Expand Down
1 change: 1 addition & 0 deletions apps/docs/docs/packages/form-intelligence/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Form Intelligence makes those workflows **declarative** on one `createForm()` in

- Sync + async validation (including multiple async checks per field)
- **HTML constraints** on DOM-backed forms (`required`, `minlength`, `type="email"`, … → validators on attach)
- **File inputs** — `File[]` from `input.files`, file validators, `toFormData()` / `payload()`; optional [`/upload`](/packages/form-intelligence/modules/upload) transport; excluded from drafts/history (not an upload framework)
- Declarative `when()` rules (show / require / populate / gate submit)
- Autosave, draft restore, wizard steps, offline submit queue
- Headless `bind()` + optional framework/schema adapters
Expand Down
2 changes: 1 addition & 1 deletion apps/form-intelligence-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@jayoncode/object-diff": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/object-diff-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@jayoncode/storage": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
Expand Down
2 changes: 1 addition & 1 deletion apps/storage-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@jayoncode/storage": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
Expand Down
45 changes: 45 additions & 0 deletions packages/form-intelligence/docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,51 @@ Merge precedence for the same validator **kind**: **Field > Schema > HTML**. Cus

Deferred: `min` / `max` / `step` / `multiple` / date-time constraints, MutationObserver re-extraction.

## File inputs (Phase A + B)

Form Intelligence **orchestrates** native file fields; it does **not** manage uploads.

| Rule | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Read | `input.files` → canonical `File[]` in form values |
| Never read | `input.value` (fake path string) |
| State → DOM | Never restore a file selection; clear only |
| Persistence | File fields are browser-owned ephemeral values — omitted from drafts, autosave, offline queue, and history |
| Validation | `required` (presence) plus `accept`, `maxSize`, `minSize`, `maxFiles`, `minFiles` |
| Submit | `onSubmit` values, or `form.toFormData()` / `form.payload()` — apps own the network transport (optional helper: [`uploadTransport`](/packages/form-intelligence/modules/upload)) |

```ts
import { createForm, required, accept, maxSize, maxFiles } from "@jayoncode/form-intelligence";

const form = createForm({
target: "#profile",
schema: {
name: { required: true },
avatar: "file",
},
validators: {
avatar: [required, accept("image/*"), maxSize("5MB"), maxFiles(1)],
},
async onSubmit() {
await fetch("/api/profile", { method: "POST", body: form.toFormData() });
},
});
```

```html
<form id="profile">
<input name="name" required />
<input name="avatar" type="file" accept="image/*" />
<button type="submit">Save</button>
</form>
```

`form.payload()` returns `{ kind: "json", values }` when no files are selected, or `{ kind: "multipart", formData }` when any file selection is non-empty.

`field().bind()` returns a file-shaped binding (`kind: "file"`, `files`, no controlled `value`) for file fields — do not spread it onto the input as controlled props.

For abortable multipart upload with progress, use the opt-in [`@jayoncode/form-intelligence/upload`](/packages/form-intelligence/modules/upload) plugin. Form Intelligence still does **not** ship cloud SDKs, chunking, or resumable protocols.

```ts
createForm({
target: "#register",
Expand Down
2 changes: 2 additions & 0 deletions packages/form-intelligence/docs/entrypoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Tree-shakeable imports for `@jayoncode/form-intelligence`. Prefer the **narrowes
| DevTools inspector | `@jayoncode/form-intelligence/devtools` |
| Derived UI projection (`showError`, `canSubmit`, `status`, …) | `@jayoncode/form-intelligence/ui` |
| CAPTCHA Security Stage (`captcha`, providers) | `@jayoncode/form-intelligence/captcha` |
| Opt-in multipart upload (`uploadTransport`) | `@jayoncode/form-intelligence/upload` |
| Browser-lifecycle / keyboard plugins | `@jayoncode/form-intelligence/plugins` |
| Error helpers (`toNormalizedErrors`, …) | `@jayoncode/form-intelligence/validation` |

Expand Down Expand Up @@ -44,6 +45,7 @@ Formatter masks use a `format*` prefix on `/format` only (`formatPhone`, `format
| `…/devtools` | `enableFormDevTools`, `getFormDevTools` | **DevTools: this path only** |
| `…/ui` | Derived UI projection: `ui()`, `createUiProjection`, `showError`, `canSubmit`, `status`, `explain()` — [UI projection → free helpers](/packages/form-intelligence/modules/ui-projection#free-helpers--policy-store-advanced) | **Projection: this path** |
| `…/captcha` | CAPTCHA Security Stage: `captcha()`, `turnstile()`, `recaptcha()`, `hcaptcha()`, `mockCaptcha()` | **CAPTCHA: this path only** |
| `…/upload` | Opt-in multipart upload transport: `uploadTransport()` — [Upload transport](/packages/form-intelligence/modules/upload) | **Upload: this path only** |
| `…/analytics` | `FormAnalyticsTracker`, `createAnalyticsPlugin` — [Integrations → analytics module API](/packages/form-intelligence/modules/integrations#analytics-module-api) | Prefer `workflow.analytics` |
| `…/adapters` | Adapter types, `createFormController` | Controller also on main |
| `…/dependency` | Dependency engine, `detectDependencyCycles` | `dependencies` also on main |
Expand Down
2 changes: 1 addition & 1 deletion packages/form-intelligence/docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import { createBrowserLifecyclePlugin } from "@jayoncode/form-intelligence/plugi

### Bundle budget (`core-login`)

Entry-chunk gzip budget is **27 KB** (ADR-013). Measure with `pnpm --filter @jayoncode/form-intelligence check:size` after `tsc -b`. See [Performance](/packages/form-intelligence/modules/performance).
Entry-chunk gzip budget is **28 KB** (ADR-013). Measure with `pnpm --filter @jayoncode/form-intelligence check:size` after `tsc -b`. See [Performance](/packages/form-intelligence/modules/performance).

### Controllers / accessibility

Expand Down
1 change: 1 addition & 0 deletions packages/form-intelligence/docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Form Intelligence makes those workflows **declarative** on one `createForm()` in

- Sync + async validation (including multiple async checks per field)
- **HTML constraints** on DOM-backed forms (`required`, `minlength`, `type="email"`, … → validators on attach)
- **File inputs** — `File[]` from `input.files`, file validators, `toFormData()` / `payload()`; optional [`/upload`](/packages/form-intelligence/modules/upload) transport; excluded from drafts/history (not an upload framework)
- Declarative `when()` rules (show / require / populate / gate submit)
- Autosave, draft restore, wizard steps, offline submit queue
- Headless `bind()` + optional framework/schema adapters
Expand Down
17 changes: 12 additions & 5 deletions packages/form-intelligence/docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ Budgets below measure **entry chunks**, not “all features in one bundle.”

Measured as **entry chunk** gzip (esbuild minify + `splitting: true`) so dynamic `import()` of offline/analytics/object-diff/integrations stays out of the login graph.

| Fixture | maxGzipKb | Notes |
| ---------------- | --------- | ----------------------------------------------------------------------------------- |
| `core-login` | 27 | `createForm` login graph; forbids DevTools / offline queue class / analytics module |
| `workflow-rules` | 3 | Rules-only subpath |
| `format-only` | 2 | Format subpath |
| Fixture | maxGzipKb | Notes |
| ---------------- | --------- | ----------------------------------------------------------------------------------------- |
| `core-login` | 28 | `createForm` login graph; forbids DevTools / offline queue class / analytics / upload XHR |
| `workflow-rules` | 3 | Rules-only subpath |
| `format-only` | 2 | Format subpath |

### Ratcheting (ADR-013)

Expand All @@ -49,6 +49,13 @@ Measured as **entry chunk** gzip (esbuild minify + `splitting: true`) so dynamic
- core-login entry ≈ **26.1 KB** gzip with merge + extract on the always-on graph
- Raised budget **26 → 27**; DevTools / offline / captcha forbid list unchanged

### File fields + upload stage (2026-07)

- Phase A/B file orchestration (`File[]`, `toFormData` / `payload`, ephemeral omit) lives on the main `createForm` graph
- Opt-in `/upload` XHR stays out of core (`xhrMultipartUpload` / `uploadTransport` forbid needles); submit override WeakMap mirrors Security Stage under `/submission`
- core-login entry ≈ **27.8 KB** gzip
- Raised budget **27 → 28**; DevTools / offline / captcha / upload XHR forbid list unchanged

## Timing budgets (Vitest)

See `tests/performance/performance-budgets.test.ts`.
Expand Down
1 change: 1 addition & 0 deletions packages/form-intelligence/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Add cross-cutting behavior — analytics, guards, or integrations — without fo
| Middleware stage maps | `@jayoncode/form-intelligence/middleware` |
| UI policies (`ui()`) | `@jayoncode/form-intelligence/ui` |
| CAPTCHA (`captcha()`, `turnstile()`) | `@jayoncode/form-intelligence/captcha` |
| Upload transport (`uploadTransport()`) | `@jayoncode/form-intelligence/upload` |
| DevTools inspector | `@jayoncode/form-intelligence/devtools` |

Full map: [Entrypoints](/packages/form-intelligence/modules/entrypoints).
Expand Down
102 changes: 102 additions & 0 deletions packages/form-intelligence/docs/upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Upload transport

Opt-in multipart upload with progress and abort — without turning Form Intelligence into an upload framework.

**Related:** [Adapters → File inputs](/packages/form-intelligence/modules/adapters#file-inputs) · [Submission](/packages/form-intelligence/modules/submission) · [Entrypoints](/packages/form-intelligence/modules/entrypoints)

## Import path

```ts
import { uploadTransport } from "@jayoncode/form-intelligence/upload";
```

| Need | Import |
| ------------------------ | ------------------------------------- |
| Plugin + XHR helper | `@jayoncode/form-intelligence/upload` |
| File values / validators | main package (see Adapters) |

Core stays free of upload networking unless this plugin is registered.

## Basic usage

```ts
import { createForm } from "@jayoncode/form-intelligence";
import { uploadTransport } from "@jayoncode/form-intelligence/upload";

createForm({
target: "#profile",
initialValues: { name: "", avatar: [] },
schema: { avatar: "file" },
plugins: [
uploadTransport({
url: "/api/upload",
onProgress: (p) => {
// p.loaded / p.total / p.percent (percent may be null)
},
}),
],
onSubmit(values, meta) {
// Optional: runs after a successful upload when files were sent.
// meta.upload → { status, responseText, response }
},
});
```

By default (`whenFilesOnly: true`), JSON-only submits call `onSubmit` as usual. Submits that include files POST multipart `FormData` via XHR (so upload progress works).

Cancel with `form.cancelSubmit()` — the transport honors the submit `AbortSignal`. A late XHR `onload` after cancel is treated as abort (no `onComplete` / `onSubmit`).

## Options

| Option | Default | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `url` | — | Target URL for the built-in XHR transport (required unless `transport` / `buildRequest` supplies one) |
| `method` | `"POST"` | HTTP method |
| `headers` | — | Request headers (`Content-Type` is omitted so the browser sets the multipart boundary) |
| `buildRequest` | — | Per-submit `{ url, method?, headers? }` from values + FormData |
| `transport` | — | Custom uploader `(formData, { values, signal, onProgress })` — use for presigned / direct-to-cloud |
| `whenFilesOnly` | `true` | Only intercept multipart payloads; otherwise always upload `FormData` |
| `onProgress` | — | `{ loaded, total, percent }` |
| `onComplete` | — | Called with `{ status, responseText, response }` |
| `onError` | — | Called before the error is rethrown into the submit pipeline |

## Events

| Event | Payload |
| ----------------- | ---------------- |
| `upload:progress` | `UploadProgress` |
| `upload:complete` | `UploadResult` |
| `upload:error` | `unknown` |

```ts
form.on("upload:progress", (progress) => {
/* … */
});
```

## Custom transport

Presigned URLs and cloud SDKs stay **app-owned**:

```ts
uploadTransport({
transport: async (formData, { signal, onProgress }) => {
// Your PUT/POST to a presigned URL, S3 SDK, etc.
// Call onProgress when you can; honor signal.
return { status: 200, responseText: "", response: null };
},
});
```

## Explicit non-goals

- Chunking / resumable / tus
- Built-in S3 / Azure / GCS clients
- Background uploads / service workers
- Persisting file selections in drafts (see Phase A ephemeral rules)

## Related

- [Adapters → File inputs](/packages/form-intelligence/modules/adapters#file-inputs)
- [Submission](/packages/form-intelligence/modules/submission)
- [Plugins](/packages/form-intelligence/modules/plugins)
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ Fixtures:

| Fixture | Import surface | Budget (gzip) |
| ---------------- | ----------------------------------- | ------------- |
| `core-login` | `createForm` + validators from main | 27 KB |
| `core-login` | `createForm` + validators from main | 28 KB |
| `workflow-rules` | `when` from `/rules` | 3 KB |
| `format-only` | formatters from `/format` | 2 KB |

Expand Down
4 changes: 4 additions & 0 deletions packages/form-intelligence/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@
"./captcha": {
"types": "./dist/captcha/index.d.ts",
"import": "./dist/captcha/index.js"
},
"./upload": {
"types": "./dist/upload/index.d.ts",
"import": "./dist/upload/index.js"
}
},
"repository": {
Expand Down
6 changes: 4 additions & 2 deletions packages/form-intelligence/scripts/bundle-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"id": "core-login",
"file": "core-login.mjs",
"maxGzipKb": 27,
"maxGzipKb": 28,
"forbid": [
"OfflineSubmitQueue",
"createDevToolsPlugin",
Expand All @@ -14,7 +14,9 @@
"js.hcaptcha.com",
"turnstile",
"grecaptcha",
"hcaptcha"
"hcaptcha",
"xhrMultipartUpload",
"uploadTransport"
]
},
{
Expand Down
1 change: 1 addition & 0 deletions packages/form-intelligence/scripts/check-entry-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const entries = [
"history/index.js",
"ui/index.js",
"captcha/index.js",
"upload/index.js",
];

console.log("@jayoncode/form-intelligence dist entry sizes (raw / gzip):\n");
Expand Down
Loading