Formosh — Knowledge Bundle
Documentation bundle for Formosh, a JSON Schema (draft 2020-12) form generator for Gleam / Lustre.
Open source Repository Open in the app JSON README (API)
About
# Formosh — Knowledge Bundle
[Formosh](https://github.com/radionest/gleam_formosh) is a type-safe **JSON
Schema form generator** for [Gleam](https://gleam.run) /
[Lustre](https://hexdocs.pm/lustre/). Hand it a JSON Schema (draft 2020-12);
it hands you back a live, validated form built on Lustre's Model-View-Update
architecture.
**You don't need to write Gleam to use it.** Formosh ships as a Web
Component (`<formosh-form>`) with a CDN bundle, so a plain HTML + JavaScript
page can mount a fully validated form from a schema with no build step and
no Gleam toolchain. The Gleam API is there for embedding inside Lustre apps;
the Web Component is there for everything else.
This directory is an [Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) (OKF v0.1) bundle — a folder of
markdown files with YAML frontmatter. Every concept file carries a `type` in
its frontmatter (the only key OKF strictly requires); `index.md` files carry
no concept
Details
- Kind
- OKF bundles
- Topic
- Files & documents
- Publisher
- radionest
- Origin
- okf_github
- Category
- dados
- Version
- 0.1
- Open pull requests
- 1
- Last push
- 2026-08-09T22:12:27Z
- Repository state
- ativo
- Language
- Gleam
- Added
- 2026-09-09 12:02:17
- Updated
- 2026-09-09 12:02:17
- Origin id
radionest/gleam_formosh:docs/index.md
README
# Formosh
JSON Schema form generator for Gleam. Parses JSON Schema (draft 2020-12) and renders dynamic forms using [Lustre](https://hexdocs.pm/lustre/) MVU architecture.
> **Alpha / learning project.** API is unstable and will change. Use at your own risk.
## Documentation
Full documentation lives in [`docs/`](https://github.com/radionest/gleam_formosh/blob/HEAD/docs/index.md) — concepts, guides
(quickstart, web component, styling, configuration), the API reference, and
the JSON Schema support matrix. This README is a quick introduction;
`docs/` is the source of truth. Planned work: [`ROADMAP.md`](https://github.com/radionest/gleam_formosh/blob/HEAD/ROADMAP.md).
## Installation
Not yet published on Hex — add Formosh as a path (or git) dependency:
```toml
target = "javascript"
[dependencies]
# clone https://github.com/radionest/gleam_formosh next to your project:
formosh = { path = "../gleam_formosh" }
```
## Quick Start
```gleam
import formosh
import lustre
pub fn main() {
let schema = "
{
\"type\": \"object\",
\"title\": \"Contact\",
\"properties\": {
\"name\": { \"type\": \"string\", \"title\": \"Name\" },
\"email\": { \"type\": \"string\", \"format\": \"email\" }
},
\"required\": [\"name\", \"email\"]
}"
let assert Ok(app) = formosh.from_json_string(schema)
let assert Ok(_) = lustre.start(app, "#app", Nil)
}
```
## Configuration
Builder pattern for customizing form behavior:
```gleam
import formosh
import formosh/schema/parser
import formosh/schema/types
import gleam/dict
let assert Ok(schema) = parser.parse_schema(json_string)
let app = formosh.config(schema)
|> formosh.with_submit_url("https://api.example.com/submit")
|> formosh.with_show_readonly_fields(True)
|> formosh.with_initial_values(dict.from_list([
#("patient_id", types.StringValue("12345")),
]))
|> formosh.from_config()
let assert Ok(_) = lustre.start(app, "#app", Nil)
```
### Submission options
**HTTP POST/PUT:**
```gleam
formosh.config(schema)
|> formosh.with_http_submit(
"https://api.example.com/forms",
"POST",
[#("Authorization", "Bearer token123"), #("Content-Type", "application/json")]
)
```
**Custom handler:**
```gleam
formosh.config(schema)
|> formosh.with_custom_submit(fn(model) {
let values = formosh.get_values(model)
// your logic
Ok("Done")
})
```
**No submission** (default) — read values manually via `formosh.get_values(model)`.
## Web Component
Use as a custom HTML element without writing Gleam:
```html
<script type="module">
import { register } from "./build/dev/javascript/formosh/formosh/component.mjs";
register();
</script>
<formosh-form
schema='{"type": "object", "properties": {"name": {"type": "string"}}}'
submit-url="https://api.example.com/submit"
submit-method="POST"
initial-values='{"name": "John"}'>
</formosh-form>
<script>
const form = document.querySelector('formosh-form');
form.addEventListener('formosh-change', (e) => {
console.log('Values:', e.detail.values);
console.log('Valid:', e.detail.isValid);
});
form.addEventListener('formosh-submit', (e) => {
console.log('Submitted:', e.detail);
});
</script>
```
Events: `formosh-ready`, `formosh-change`, `formosh-submitting`, `formosh-submit`.
Set `read-only="true"` (or `component.read_only(True)` programmatically) to
render the form as a static label→value summary instead of inputs: enums show
their label, booleans Yes/No, nested objects as groups, arrays of flat objects
as tables; Submit/Reset are hidden. Useful for displaying stored values of
records that are not editable. Style it via the `readonly-*` parts (see below).
Or use inside a Lustre app programmatically:
```gleam
import formosh/component
// After component.register()
component.element([
component.schema(my_schema),
component.submit_url("https://api.example.com/submit"),
component.on_change(HandleFormChange),
])
```
## Schema Examples
### Nested objects
```json
{
"type": "object",
"properties": {
"address": {
"type": "object",
"title": "Address",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
},
"required": ["street", "city"]
}
}
}
```
### Arrays with add/remove
```json
{
"type": "object",
"properties": {
"skills": {
"type": "array",
"title": "Skills",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Skill" },
"level": {
"type": "string",
"enum": ["Beginner", "Intermediate", "Advanced", "Expert"]
}
}
}
}
}
}
```
`minItems` / `maxItems` bound the row count: the form auto-creates rows (with
item-field defaults applied) up to `minItems`, hides the remove button when
shrinking would violate `minItems`, and hides the add button once `maxItems`
is reached. Violations coming from externally supplied values are reported
as validation errors on the array itself and are always visible (they skip
the usual touched gate — button gating means they can never be caused by
form interaction, so the message is the only explanation for a blocked
submit). A schema with `minItems > maxItems` (unsatisfiable) is normalized
at parse time so `minItems` wins: the array renders as fixed-size at
`minItems` rows.
### Conditional fields (if/then/else)
Fields appear/disappear based on other field values:
```json
{
"type": "object",
"properties": {
"hasLicense": { "type": "boolean", "title": "Do you have a license?" }
},
"if": {
"properties": { "hasLicense": { "const": true } }
},
"then": {
"properties": {
"licenseNumber": { "type": "string", "title": "License Number" },
"expiryDate": { "type": "string", "format": "date", "title": "Expiry Date" }
},
"required": ["licenseNumber"]
}
}
```
Also supports multiple conditionals via `allOf`:
```json
{
"allOf": [
{
"if": { "properties": { "type": { "const": "company" } } },
"then": { "properties": { "companyName": { "type": "string" } } }
},
{
"if": { "properties": { "type": { "const": "individual" } } },
"then": { "properties": { "fullName": { "type": "string" } } }
}
]
}
```
Conditionals compose with array constraints: declare a whole array inside `then`
with `minItems` to make it appear — pre-populated with its first default-hydrated
row — only once the condition is met. See
[`demo/schemas/carcinomatosis_radiology.json`](https://github.com/radionest/gleam_formosh/blob/HEAD/demo/schemas/carcinomatosis_radiology.json)
for a worked example (`lesions` appears per-zone when `affected` is true).
`$ref` is resolved inside `if`/`then`/`else` branches, so conditional branches
can reference `$defs` definitions directly.
### $ref and $defs
```json
{
"$defs": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
},
"type": "object",
"properties": {
"billing": { "$ref": "#/$defs/address", "title": "Billing Address" },
"shipping": { "$ref": "#/$defs/address", "title": "Shipping Address" }
}
}
```
Supports `#/$defs/...` and `#/definitions/...` JSON Pointers. Circular references are detected and rejected.
### oneOf (select from schema variants)
```json
{
"type": "object",
"properties": {
"status": {
"type": "string",
"title": "Status",
"oneOf": [
{ "const": "active", "title": "Active" },
{ "const": "inactive", "title": "Inactive" },
{ "const": "pending", "title": "Pending Review" }
]
}
}
}
```
### anyOf (union types)
```json
{
"type": "object",
"properties": {
"contact": {
"title": "Contact",
"anyOf": [
{ "type": "integer", "title": "Phone extension" },
{ "type": "string", "title": "Note" },
{ "$ref": "#/$defs/Address" }
]
},
"optional_score": {
"anyOf": [
{ "type": "integer" },
{ "type": "null" }
]
}
},
"$defs": {
"Address": {
"type": "object",
"title": "Address",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
}
}
```
Two or more non-null members (`contact` above) render as a branch chooser —
radio buttons for ≤5 branches, a select dropdown for more (same threshold as
`enum`; override with `ui:widget: "select"` or `"radio"`) — followed by the
active branch's own widget. Each option's label is the member's `title`
(`$ref` members inherit the referenced `$defs` title, so `Address` shows up
correctly), falling back to the JSON type name, then `"Option N"`. Switching
branches clears the field's previous value and re-applies the new branch's
own defaults; inside an array row, switching only resets that row, not its
neighbors. A **bare** `anyOf` directly as an array's `items` schema (no
object wrapper) does not render a chooser — wrap it in an object property.
A single non-null member alongside `{"type": "null"}` collapses into a plain
nullable field instead of a chooser — this is what a Pydantic `Optional[int]`
serializes to: `optional_score` above renders as an ordinary number input
that happens to be nullable. Leaving it empty submits `null`, and it shows no
required asterisk even when the field is named in `required`.
`oneOf` does not get this treatment: only `const`+`title` options (above)
render; general schema-variant `oneOf` is parsed but not selectable.
## Field Rendering Rules
The widget is chosen automatically based on schema:
| Schema | Widget |
|--------|--------|
| `string` | text input |
| `string` + `maxLength > 100` | textarea |
| `string` + `enum` (≤5 options) | radio buttons |
| `string` + `enum` (>5 options) | select dropdown |
| `string` + `oneOf` with const/title | radio buttons |
| `anyOf` (2+ non-null branches) | branch chooser (radio ≤5, select >5) + the active branch's own widget |
| `anyOf` (one non-null branch + `null`, i.e. `Optional[X]`) | plain `X` widget — nullable, no required asterisk, empty submits `null` |
| `string` + `format: "email"` | email input |
| `string` + `format: "url"` or `"uri"` | url input |
| `string` + `format: "date"` | date input — native picker |
| `string` + `format: "time"` | time input — native picker |
| `string` + `format: "password"` or `ui:widget: "password"` | password input — masked; wins over the `maxLength > 100` textarea rule above regardless of route |
| `string` + `format: "date-time"` | text input — deliberately not wired (see `ROADMAP.md`) |
| `number` / `integer` | number input (with `step` from `multipleOf`) |
| `boolean` | Yes/No radio buttons |
| `array` | dynamic list with add/remove controls |
| `object` | nested fieldset |
| `readOnly: true` | hidden by default; shown as readonly input with `with_show_readonly_fields(True)` |
| `object` + `ui:widget: "swipe-review"` | tap/swipe-based zone burndown |
## What's Implemented
### JSON Schema keywords
- **Types:** `string`, `number`, `integer`, `boolean`, `array`, `object`, `null`
- **Structure:** `properties`, `items` (objects and arrays nest to any depth, including arrays inside array items), `required`, `$defs`/`definitions`, `$ref`
- **Metadata:** `title`, `description`, `default`, `readOnly`
- **Enum:** `enum`, `const` (converted to single-value enum)
- **Composition:** `oneOf` (with const+title options), `allOf` (deep-merges member schemas — properties, required, bounds, `$ref` mixins — at parse time, lifts member conditionals to the parent, and can type an otherwise-typeless schema root or resolve a root-level `$ref`; an unsatisfiable composition — conflicting `type`s or crossed bounds in a composed node's merged constraints — fails parsing with `UnsatisfiableSchema` rather than silently producing one that validates nothing; see [`demo/schemas/composition_test.json`](https://github.com/radionest/gleam_formosh/blob/HEAD/demo/schemas/composition_test.json) for a worked example), `anyOf` (null members collapse into a `nullable` flag; a single surviving member merges into the node; 2+ surviving members render as a runtime branch chooser — see [anyOf (union types)](#anyof-union-types) above)
- **Conditional:** `if`/`then`/`else` — fully dynamic, re-evaluated on every field change
- **String constraints:** `minLength`, `maxLength`, `format` (date, email, password, url/uri, time, date-time, uuid)
- **Number constraints:** `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`
- **Array constraints:** `minItems`, `maxItems` — length validation, add/remove button gating, and auto-created rows up to `minItems`
### Validation
- Required field checks
- String length bounds (minLength, maxLength)
- Number bounds (min, max, exclusive, multipleOf)
- Array length bounds (minItems, maxItems)
- Basic format validation: email (checks `@` and `.`), url (checks `http(s)://` prefix)
### Other
- HTTP form submission (POST, PUT) via [rsvp](https://hexdocs.pm/rsvp/)
- Custom submission handlers
- Web Component (`<formosh-form>`) with attribute listeners and custom events
- Initial values pre-population
- Touch tracking — errors shown only after field interaction
- Conditional field visibility — fields appear/disappear based on form state
- Schema serialization back to JSON
## What's NOT Implemented
- `oneOf` schema-variant (polymorphic) dispatch — only `const`+`title` options render as a choice widget; unlike `anyOf`, general `oneOf` schema branches are parsed but not selectable
- A bare `anyOf` directly as an array's `items` schema (no object wrapper) — no branch chooser renders; wrap the union in an object property instead
- Unions inside array rows: hidden-field suppression and read-only table columns do not per-row-resolve the active branch (issue #86)
- `not`
- `allOf` enum/`oneOf` intersection — colliding `enum`/`oneOf` values take the later member's list wholesale
- `allOf` inside a `$defs` entry does not survive schema serialization round-trip (`$defs` stay raw; the serializer re-emits flattened schemas)
- `additionalProperties`, `patternProperties`
- `dependencies`, `dependentRequired`, `dependentSchemas`
- `prefixItems` (tuple validation)
- `minProperties`, `maxProperties`
- `discriminator`
- GET submission method
- RFC-compliant email/URL format validation
## Styling
The component runs inside an open Shadow DOM. There are three customization surfaces:
1. **`::part()` selectors** — every styled element exposes a `part` name (the class suffix without `formosh-`). Style from outside:
```css
formosh-form::part(input) { border: 1px solid #d33; }
formosh-form::part(label) { font-weight: 600; }
formosh-form::part(error) { color: orange; }
formosh-form::part(submit) { background: #08a; color: white; }
```
2. **`data-*` attributes for state** — error and readonly states on the field wrapper:
```css
formosh-form::part(field)[data-error] { border-color: red; }
formosh-form::part(field)[data-readonly] { opacity: 0.6; }
```
3. **Parent stylesheets are auto-adopted** — Lustre clones the parent document's CSS into the shadow root, so plain class selectors still work:
```css
.formosh-input { padding: 0.5rem; }
.formosh-error { color: red; }
```
Part names available (most elements carry one; a few carry two — see **Compound parts** below): `container`, `header`, `title`, `description`, `form`, `footer`, `submit`, `reset`, `success`, `error-message`, `loading`, `row`, `group`, `group-label`, `group-body` (the last four appear only where a `ui:layout` actually places a `Row` or `Group` node; tune the row gap with the `--formosh-row-gap` custom property), `field`, `field-wrapper`, `label`, `required`, `help`, `errors`, `error`, `input`, `number`, `textarea`, `select`, `radio-group`, `radio-item`, `boolean`, `checkbox-wrapper`, `checkbox-group`, `array-field`, `array-items`, `array-item`, `array-item-fields`, `array-item-header`, `array-add`, `union`, `union-radio`, `union-select`, `image-upload`, `image-grid`, `image-card`, `image-preview`, `image-add`, `image-remove`, `image-uploading`, `image-spinner`, `image-error`, `image-error-text`. Read-only (review) mode adds: `readonly-field`, `readonly-label`, `readonly-value`, `readonly-group`, `readonly-group-label`, `readonly-group-body`, `readonly-table`, `readonly-th`, `readonly-td`. Swipe-review widget adds: `swipe-review`, `swipe-sheet`, `swipe-regions`, `swipe-region-group`, `swipe-region`, `swipe-zones`, `swipe-row`, `swipe-zone-title`, `swipe-choices`, `swipe-choice`, `swipe-progress`, `swipe-controls`, `swipe-toggle`, `swipe-undo`, `swipe-fill`, `swipe-review-summary`, `swipe-review-title`, `swipe-review-list`, `swipe-review-row`, `swipe-review-zone`, `swipe-review-answer`. Collapse-completed arrays (`ui:options.collapseCompleted`) add: `array-collapse-header`, `array-toggle`, `array-progress`, `array-item-summary`, `array-item-summary-value`, `array-item-summary-sep`, `array-item-body` (the folding wrapper — carries the fold animation as inline styles; retime it with `--formosh-collapse-duration`).
Notes:
- **Cascade**: adopted parent stylesheets and host-level `::part()` rules cascade by normal CSS specificity. To override a `.formosh-*` class rule, give your `::part()` selector higher specificity or use a more specific compound condition (`::part(input):not(:disabled)`).
- **Compound parts**: elements that carry two part tokens (e.g. `part="radio-group boolean"`) are reachable through either token. `::part()` does not support descendant combinators — so `radio-item` inside a boolean group cannot be addressed differently from one inside an enum group through Shadow Parts alone.
Essentially no default styles are included — bring your own CSS. The
exceptions are a few narrow inline styles that opt-in features cannot work
without (a `ui:layout` `Row`'s grid, a collapsing array row's fold, the
swipe widget's drag transforms); `docs/guides/styling.md` lists them.
## Development
```bash
gleam deps download # install dependencies
gleam build # build
gleam test # run tests
gleam format # format code
make demo # interactive demo on http://localhost:1234 (picks a schema, mounts <formosh-form>)
make demo-server # echo backend for form submissions on port 8888 (optional)
npm run build # build CDN bundle into dist/
```
The interactive demo lives in `demo/` as a standalone Gleam project that depends on the library via `formosh = { path = ".." }`. Add JSON Schemas to `demo/schemas/` and they become selectable in the UI (see `demo/src/demo.gleam`).
## API Reference
```gleam
// Create from JSON string
formosh.from_json_string(json: String) -> Result(App, ParseError)
// Create from parsed schema
formosh.from_schema(schema: JsonSchema) -> App
// Configuration builder
formosh.config(schema: JsonSchema) -> FormConfig
formosh.from_config(config: FormConfig) -> App
formosh.with_submit_url(config, url) -> FormConfig
formosh.with_http_submit(config, url, method, headers) -> FormConfig
formosh.with_custom_submit(config, handler) -> FormConfig
formosh.with_show_errors_on_change(config, show) -> FormConfig // currently a no-op, see ROADMAP.md
formosh.with_show_readonly_fields(config, show) -> FormConfig
formosh.with_initial_values(config, values) -> FormConfig
// Read form state
formosh.get_values(model: FormModel) -> Value // tree, ObjectValue at root
// Web Component
component.register() -> Result(Nil, Error)
component.element(attributes) -> Element(msg)
```
## License
MIT