{
  "markdown": "# Formosh\n\nJSON Schema form generator for Gleam. Parses JSON Schema (draft 2020-12) and renders dynamic forms using [Lustre](https://hexdocs.pm/lustre/) MVU architecture.\n\n> **Alpha / learning project.** API is unstable and will change. Use at your own risk.\n\n## Documentation\n\nFull documentation lives in [`docs/`](docs/index.md) — concepts, guides\n(quickstart, web component, styling, configuration), the API reference, and\nthe JSON Schema support matrix. This README is a quick introduction;\n`docs/` is the source of truth. Planned work: [`ROADMAP.md`](ROADMAP.md).\n\n## Installation\n\nNot yet published on Hex — add Formosh as a path (or git) dependency:\n\n```toml\ntarget = \"javascript\"\n\n[dependencies]\n# clone https://github.com/radionest/gleam_formosh next to your project:\nformosh = { path = \"../gleam_formosh\" }\n```\n\n## Quick Start\n\n```gleam\nimport formosh\nimport lustre\n\npub fn main() {\n  let schema = \"\n  {\n    \\\"type\\\": \\\"object\\\",\n    \\\"title\\\": \\\"Contact\\\",\n    \\\"properties\\\": {\n      \\\"name\\\": { \\\"type\\\": \\\"string\\\", \\\"title\\\": \\\"Name\\\" },\n      \\\"email\\\": { \\\"type\\\": \\\"string\\\", \\\"format\\\": \\\"email\\\" }\n    },\n    \\\"required\\\": [\\\"name\\\", \\\"email\\\"]\n  }\"\n\n  let assert Ok(app) = formosh.from_json_string(schema)\n  let assert Ok(_) = lustre.start(app, \"#app\", Nil)\n}\n```\n\n## Configuration\n\nBuilder pattern for customizing form behavior:\n\n```gleam\nimport formosh\nimport formosh/schema/parser\nimport formosh/schema/types\nimport gleam/dict\n\nlet assert Ok(schema) = parser.parse_schema(json_string)\n\nlet app = formosh.config(schema)\n  |> formosh.with_submit_url(\"https://api.example.com/submit\")\n  |> formosh.with_show_readonly_fields(True)\n  |> formosh.with_initial_values(dict.from_list([\n    #(\"patient_id\", types.StringValue(\"12345\")),\n  ]))\n  |> formosh.from_config()\n\nlet assert Ok(_) = lustre.start(app, \"#app\", Nil)\n```\n\n### Submission options\n\n**HTTP POST/PUT:**\n\n```gleam\nformosh.config(schema)\n  |> formosh.with_http_submit(\n    \"https://api.example.com/forms\",\n    \"POST\",\n    [#(\"Authorization\", \"Bearer token123\"), #(\"Content-Type\", \"application/json\")]\n  )\n```\n\n**Custom handler:**\n\n```gleam\nformosh.config(schema)\n  |> formosh.with_custom_submit(fn(model) {\n    let values = formosh.get_values(model)\n    // your logic\n    Ok(\"Done\")\n  })\n```\n\n**No submission** (default) — read values manually via `formosh.get_values(model)`.\n\n## Web Component\n\nUse as a custom HTML element without writing Gleam:\n\n```html\n<script type=\"module\">\n  import { register } from \"./build/dev/javascript/formosh/formosh/component.mjs\";\n  register();\n</script>\n\n<formosh-form\n  schema='{\"type\": \"object\", \"properties\": {\"name\": {\"type\": \"string\"}}}'\n  submit-url=\"https://api.example.com/submit\"\n  submit-method=\"POST\"\n  initial-values='{\"name\": \"John\"}'>\n</formosh-form>\n\n<script>\n  const form = document.querySelector('formosh-form');\n  form.addEventListener('formosh-change', (e) => {\n    console.log('Values:', e.detail.values);\n    console.log('Valid:', e.detail.isValid);\n  });\n  form.addEventListener('formosh-submit', (e) => {\n    console.log('Submitted:', e.detail);\n  });\n</script>\n```\n\nEvents: `formosh-ready`, `formosh-change`, `formosh-submitting`, `formosh-submit`.\n\nSet `read-only=\"true\"` (or `component.read_only(True)` programmatically) to\nrender the form as a static label→value summary instead of inputs: enums show\ntheir label, booleans Yes/No, nested objects as groups, arrays of flat objects\nas tables; Submit/Reset are hidden. Useful for displaying stored values of\nrecords that are not editable. Style it via the `readonly-*` parts (see below).\n\nOr use inside a Lustre app programmatically:\n\n```gleam\nimport formosh/component\n\n// After component.register()\ncomponent.element([\n  component.schema(my_schema),\n  component.submit_url(\"https://api.example.com/submit\"),\n  component.on_change(HandleFormChange),\n])\n```\n\n## Schema Examples\n\n### Nested objects\n\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"address\": {\n      \"type\": \"object\",\n      \"title\": \"Address\",\n      \"properties\": {\n        \"street\": { \"type\": \"string\" },\n        \"city\": { \"type\": \"string\" }\n      },\n      \"required\": [\"street\", \"city\"]\n    }\n  }\n}\n```\n\n### Arrays with add/remove\n\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"skills\": {\n      \"type\": \"array\",\n      \"title\": \"Skills\",\n      \"items\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": { \"type\": \"string\", \"title\": \"Skill\" },\n          \"level\": {\n            \"type\": \"string\",\n            \"enum\": [\"Beginner\", \"Intermediate\", \"Advanced\", \"Expert\"]\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n`minItems` / `maxItems` bound the row count: the form auto-creates rows (with\nitem-field defaults applied) up to `minItems`, hides the remove button when\nshrinking would violate `minItems`, and hides the add button once `maxItems`\nis reached. Violations coming from externally supplied values are reported\nas validation errors on the array itself and are always visible (they skip\nthe usual touched gate — button gating means they can never be caused by\nform interaction, so the message is the only explanation for a blocked\nsubmit). A schema with `minItems > maxItems` (unsatisfiable) is normalized\nat parse time so `minItems` wins: the array renders as fixed-size at\n`minItems` rows.\n\n### Conditional fields (if/then/else)\n\nFields appear/disappear based on other field values:\n\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"hasLicense\": { \"type\": \"boolean\", \"title\": \"Do you have a license?\" }\n  },\n  \"if\": {\n    \"properties\": { \"hasLicense\": { \"const\": true } }\n  },\n  \"then\": {\n    \"properties\": {\n      \"licenseNumber\": { \"type\": \"string\", \"title\": \"License Number\" },\n      \"expiryDate\": { \"type\": \"string\", \"format\": \"date\", \"title\": \"Expiry Date\" }\n    },\n    \"required\": [\"licenseNumber\"]\n  }\n}\n```\n\nAlso supports multiple conditionals via `allOf`:\n\n```json\n{\n  \"allOf\": [\n    {\n      \"if\": { \"properties\": { \"type\": { \"const\": \"company\" } } },\n      \"then\": { \"properties\": { \"companyName\": { \"type\": \"string\" } } }\n    },\n    {\n      \"if\": { \"properties\": { \"type\": { \"const\": \"individual\" } } },\n      \"then\": { \"properties\": { \"fullName\": { \"type\": \"string\" } } }\n    }\n  ]\n}\n```\n\nConditionals compose with array constraints: declare a whole array inside `then`\nwith `minItems` to make it appear — pre-populated with its first default-hydrated\nrow — only once the condition is met. See\n[`demo/schemas/carcinomatosis_radiology.json`](demo/schemas/carcinomatosis_radiology.json)\nfor a worked example (`lesions` appears per-zone when `affected` is true).\n`$ref` is resolved inside `if`/`then`/`else` branches, so conditional branches\ncan reference `$defs` definitions directly.\n\n### $ref and $defs\n\n```json\n{\n  \"$defs\": {\n    \"address\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"street\": { \"type\": \"string\" },\n        \"city\": { \"type\": \"string\" }\n      }\n    }\n  },\n  \"type\": \"object\",\n  \"properties\": {\n    \"billing\": { \"$ref\": \"#/$defs/address\", \"title\": \"Billing Address\" },\n    \"shipping\": { \"$ref\": \"#/$defs/address\", \"title\": \"Shipping Address\" }\n  }\n}\n```\n\nSupports `#/$defs/...` and `#/definitions/...` JSON Pointers. Circular references are detected and rejected.\n\n### oneOf (select from schema variants)\n\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"status\": {\n      \"type\": \"string\",\n      \"title\": \"Status\",\n      \"oneOf\": [\n        { \"const\": \"active\", \"title\": \"Active\" },\n        { \"const\": \"inactive\", \"title\": \"Inactive\" },\n        { \"const\": \"pending\", \"title\": \"Pending Review\" }\n      ]\n    }\n  }\n}\n```\n\n### anyOf (union types)\n\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"contact\": {\n      \"title\": \"Contact\",\n      \"anyOf\": [\n        { \"type\": \"integer\", \"title\": \"Phone extension\" },\n        { \"type\": \"string\", \"title\": \"Note\" },\n        { \"$ref\": \"#/$defs/Address\" }\n      ]\n    },\n    \"optional_score\": {\n      \"anyOf\": [\n        { \"type\": \"integer\" },\n        { \"type\": \"null\" }\n      ]\n    }\n  },\n  \"$defs\": {\n    \"Address\": {\n      \"type\": \"object\",\n      \"title\": \"Address\",\n      \"properties\": {\n        \"street\": { \"type\": \"string\" },\n        \"city\": { \"type\": \"string\" }\n      }\n    }\n  }\n}\n```\n\nTwo or more non-null members (`contact` above) render as a branch chooser —\nradio buttons for ≤5 branches, a select dropdown for more (same threshold as\n`enum`; override with `ui:widget: \"select\"` or `\"radio\"`) — followed by the\nactive branch's own widget. Each option's label is the member's `title`\n(`$ref` members inherit the referenced `$defs` title, so `Address` shows up\ncorrectly), falling back to the JSON type name, then `\"Option N\"`. Switching\nbranches clears the field's previous value and re-applies the new branch's\nown defaults; inside an array row, switching only resets that row, not its\nneighbors. A **bare** `anyOf` directly as an array's `items` schema (no\nobject wrapper) does not render a chooser — wrap it in an object property.\n\nA single non-null member alongside `{\"type\": \"null\"}` collapses into a plain\nnullable field instead of a chooser — this is what a Pydantic `Optional[int]`\nserializes to: `optional_score` above renders as an ordinary number input\nthat happens to be nullable. Leaving it empty submits `null`, and it shows no\nrequired asterisk even when the field is named in `required`.\n\n`oneOf` does not get this treatment: only `const`+`title` options (above)\nrender; general schema-variant `oneOf` is parsed but not selectable.\n\n## Field Rendering Rules\n\nThe widget is chosen automatically based on schema:\n\n| Schema | Widget |\n|--------|--------|\n| `string` | text input |\n| `string` + `maxLength > 100` | textarea |\n| `string` + `enum` (≤5 options) | radio buttons |\n| `string` + `enum` (>5 options) | select dropdown |\n| `string` + `oneOf` with const/title | radio buttons |\n| `anyOf` (2+ non-null branches) | branch chooser (radio ≤5, select >5) + the active branch's own widget |\n| `anyOf` (one non-null branch + `null`, i.e. `Optional[X]`) | plain `X` widget — nullable, no required asterisk, empty submits `null` |\n| `string` + `format: \"email\"` | email input |\n| `string` + `format: \"url\"` or `\"uri\"` | url input |\n| `string` + `format: \"date\"` | date input — native picker |\n| `string` + `format: \"time\"` | time input — native picker |\n| `string` + `format: \"password\"` or `ui:widget: \"password\"` | password input — masked; wins over the `maxLength > 100` textarea rule above regardless of route |\n| `string` + `format: \"date-time\"` | text input — deliberately not wired (see `ROADMAP.md`) |\n| `number` / `integer` | number input (with `step` from `multipleOf`) |\n| `boolean` | Yes/No radio buttons |\n| `array` | dynamic list with add/remove controls |\n| `object` | nested fieldset |\n| `readOnly: true` | hidden by default; shown as readonly input with `with_show_readonly_fields(True)` |\n| `object` + `ui:widget: \"swipe-review\"` | tap/swipe-based zone burndown |\n\n## What's Implemented\n\n### JSON Schema keywords\n\n- **Types:** `string`, `number`, `integer`, `boolean`, `array`, `object`, `null`\n- **Structure:** `properties`, `items` (objects and arrays nest to any depth, including arrays inside array items), `required`, `$defs`/`definitions`, `$ref`\n- **Metadata:** `title`, `description`, `default`, `readOnly`\n- **Enum:** `enum`, `const` (converted to single-value enum)\n- **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`](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)\n- **Conditional:** `if`/`then`/`else` — fully dynamic, re-evaluated on every field change\n- **String constraints:** `minLength`, `maxLength`, `format` (date, email, password, url/uri, time, date-time, uuid)\n- **Number constraints:** `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`\n- **Array constraints:** `minItems`, `maxItems` — length validation, add/remove button gating, and auto-created rows up to `minItems`\n\n### Validation\n\n- Required field checks\n- String length bounds (minLength, maxLength)\n- Number bounds (min, max, exclusive, multipleOf)\n- Array length bounds (minItems, maxItems)\n- Basic format validation: email (checks `@` and `.`), url (checks `http(s)://` prefix)\n\n### Other\n\n- HTTP form submission (POST, PUT) via [rsvp](https://hexdocs.pm/rsvp/)\n- Custom submission handlers\n- Web Component (`<formosh-form>`) with attribute listeners and custom events\n- Initial values pre-population\n- Touch tracking — errors shown only after field interaction\n- Conditional field visibility — fields appear/disappear based on form state\n- Schema serialization back to JSON\n\n## What's NOT Implemented\n\n- `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\n- 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\n- Unions inside array rows: hidden-field suppression and read-only table columns do not per-row-resolve the active branch (issue #86)\n- `not`\n- `allOf` enum/`oneOf` intersection — colliding `enum`/`oneOf` values take the later member's list wholesale\n- `allOf` inside a `$defs` entry does not survive schema serialization round-trip (`$defs` stay raw; the serializer re-emits flattened schemas)\n- `additionalProperties`, `patternProperties`\n- `dependencies`, `dependentRequired`, `dependentSchemas`\n- `prefixItems` (tuple validation)\n- `minProperties`, `maxProperties`\n- `discriminator`\n- GET submission method\n- RFC-compliant email/URL format validation\n\n## Styling\n\nThe component runs inside an open Shadow DOM. There are three customization surfaces:\n\n1. **`::part()` selectors** — every styled element exposes a `part` name (the class suffix without `formosh-`). Style from outside:\n\n   ```css\n   formosh-form::part(input)         { border: 1px solid #d33; }\n   formosh-form::part(label)         { font-weight: 600; }\n   formosh-form::part(error)         { color: orange; }\n   formosh-form::part(submit)        { background: #08a; color: white; }\n   ```\n\n2. **`data-*` attributes for state** — error and readonly states on the field wrapper:\n\n   ```css\n   formosh-form::part(field)[data-error]    { border-color: red; }\n   formosh-form::part(field)[data-readonly] { opacity: 0.6; }\n   ```\n\n3. **Parent stylesheets are auto-adopted** — Lustre clones the parent document's CSS into the shadow root, so plain class selectors still work:\n\n   ```css\n   .formosh-input { padding: 0.5rem; }\n   .formosh-error { color: red; }\n   ```\n\nPart 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`).\n\nNotes:\n\n- **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)`).\n- **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.\n\nEssentially no default styles are included — bring your own CSS. The\nexceptions are a few narrow inline styles that opt-in features cannot work\nwithout (a `ui:layout` `Row`'s grid, a collapsing array row's fold, the\nswipe widget's drag transforms); `docs/guides/styling.md` lists them.\n\n## Development\n\n```bash\ngleam deps download    # install dependencies\ngleam build            # build\ngleam test             # run tests\ngleam format           # format code\nmake demo              # interactive demo on http://localhost:1234 (picks a schema, mounts <formosh-form>)\nmake demo-server       # echo backend for form submissions on port 8888 (optional)\nnpm run build          # build CDN bundle into dist/\n```\n\nThe 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`).\n\n## API Reference\n\n```gleam\n// Create from JSON string\nformosh.from_json_string(json: String) -> Result(App, ParseError)\n\n// Create from parsed schema\nformosh.from_schema(schema: JsonSchema) -> App\n\n// Configuration builder\nformosh.config(schema: JsonSchema) -> FormConfig\nformosh.from_config(config: FormConfig) -> App\nformosh.with_submit_url(config, url) -> FormConfig\nformosh.with_http_submit(config, url, method, headers) -> FormConfig\nformosh.with_custom_submit(config, handler) -> FormConfig\nformosh.with_show_errors_on_change(config, show) -> FormConfig  // currently a no-op, see ROADMAP.md\nformosh.with_show_readonly_fields(config, show) -> FormConfig\nformosh.with_initial_values(config, values) -> FormConfig\n\n// Read form state\nformosh.get_values(model: FormModel) -> Value   // tree, ObjectValue at root\n\n// Web Component\ncomponent.register() -> Result(Nil, Error)\ncomponent.element(attributes) -> Element(msg)\n```\n\n## License\n\nMIT\n",
  "bytes": 19511,
  "sha": "fba1e3d02b7e80c19b3845205494128e65316e2098a821c5ffc83d4979082a35",
  "repo_slug": "radionest/gleam_formosh",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/okf_radionest_gleam_formosh_docs_index_md_0267d779/readme"
}