Feature Specification: Form Rendering

Created: 2026-09-22 Status: Implemented Input: Reconstructed from the code at d225ebe (src/index.ts, src/nullable.ts, src/nullable-renderer.ts, src/tagged-oneof-renderer.ts, src/retain.ts, src/markdown.ts, src/diff-highlight.ts, src/forms-island.css, patches/, the unit and DOM tests), the documentation (docs/usage.md, docs/comparison.md), the pull requests of fediversity/nix-module-form, the history of the former panel/forms-island and nix-module-form/ directories in the fediversity monorepo, and the Claude Code logs of the maintainers.

User Scenarios & Testing (mandatory)

User Story 1 - Edit the options of a module in a web page (Priority: P1)

A developer of a web interface has a JSON Schema for the options of a Nixpkgs module (for example from optionsToSchema of module-schema) and a stored configuration value. They call the mount function of the widget with an element, the schema and the value. The page shows one control for each option, with its description, and the developer gets the edited value through onChange.

Why this priority: this is the purpose of the widget. All other stories build on it.

Independent Test: build the demo, open the value-diff page, change a string, a number and a boolean, and read the value that onChange reports.

Acceptance Scenarios:

  1. Given a schema with a string, an integer, an enum and a boolean option, When the form mounts, Then it shows a text field, a number field, a select and a switch.
  2. Given a stored value, When the form mounts, Then each control shows the stored value.
  3. Given a mounted form, When the user changes a control, Then onChange receives the complete new value, without synthetic fields of the widget.
  4. Given a schema that uses $ref into $defs, When the form mounts, Then it renders the referenced schema.

User Story 2 - Defaults stay defaults (Priority: P1)

A user opens a form for a module where most options have defaults. The form shows each default as a placeholder, and the value that the widget reports contains only the options that the user set.

Why this priority: the module system owns the defaults (constitution principle IV). A value that copies them does not follow later changes of the module.

Independent Test: mount a schema with defaults and an empty value, and check that onChange reports an empty object while the controls show the defaults as placeholders.

Acceptance Scenarios:

  1. Given an option with a default, When the value does not contain the option, Then the control shows the default as a placeholder and the value stays without the key.
  2. Given a mandatory tagged union with a default branch, When the value holds null, {} or no key for the union, Then the widget writes the default branch into the value at mount and reports it through onChange.

User Story 3 - Optional options (nullOr) (Priority: P1)

An option of type nullOr X can be unset (null) or set. The form shows a "Configure" switch. When it is off, the value holds null or no key. When it is on, the form shows the control for X.

Why this priority: most service modules use nullOr for optional settings.

Independent Test: the DOM tests of the nullable renderer toggle the switch and read the value.

Acceptance Scenarios:

  1. Given a nullable option in any supported spelling, When the value is null, Then the switch is off and no inner control shows.
  2. Given the switch is off, When the user turns it on, Then the inner control shows and the user can enter a value.
  3. Given a nullable object with a set value, When the user turns the switch off and on again, Then the earlier inner values come back.
  4. Given a nullable option whose inner schema has default: null, When the user turns the switch on, Then the switch stays on.

User Story 4 - Choose one branch of a tagged union (attrTag) (Priority: P2)

An option of type attrTag holds exactly one of several named branches. The form shows a select with the branch names and, below it, the controls of the chosen branch.

Why this priority: some modules use attrTag to choose between alternatives of different shape.

Independent Test: the tagged-union DOM tests switch the branch and read the value.

Acceptance Scenarios:

  1. Given a tagged union, When the form mounts, Then a select shows the active branch and the controls of that branch.
  2. Given two branches that share a setting with the same schema, When the user switches the branch, Then the widget keeps the value of the shared setting.
  3. Given the user switched from branch A to branch B, When the user switches back to A, Then the values of A that the user entered come back while the form is open.
  4. Given the union node carries a hoisted type: "object", When the form mounts, Then it still renders the union as a tagged union.

User Story 5 - Maps of named entries (attrsOf) (Priority: P2)

An option of type attrsOf X holds entries with names that the user chooses. The form shows a list of entries where the user can add, rename and remove entries, and edit each entry with the control for X.

Why this priority: modules use attrsOf for virtual hosts, users and similar sets.

Independent Test: mount a schema with additionalProperties and propertyNames, add an entry, and read the value.

Acceptance Scenarios:

  1. Given an attrsOf option, When the user adds an entry with a name, Then the value holds the new key.
  2. Given a propertyNames schema that is a $ref into $defs, When the user enters a name that does not match it, Then the form shows an error.

User Story 6 - Fit the theme and the styles of the host page (Priority: P3)

The widget is embedded in a page that has its own CSS framework and light or dark theme. The form follows the theme of the page and the CSS of the page does not break the controls.

Why this priority: an embedded widget that looks broken is not usable, but the host can work around it.

Independent Test: mount the demo with data-theme="dark" on the root element, and with a page that defines .control and .input rules.

Acceptance Scenarios:

  1. Given the root element has data-theme set to light or dark, When the form mounts, Then it uses that theme. Given no attribute, Then it follows prefers-color-scheme.
  2. Given a mounted form, When the host calls setTheme("dark"), Then the form changes to the dark theme.
  3. Given a host page with global .control or .input rules, When the form mounts, Then the rules do not apply to the controls of the widget.
  4. Given a host that passes primaryColor, When the form mounts, Then the inputs and switches use that color. Given no primaryColor, Then they use the default.
  5. Given a mounted form, When the host page has other elements, Then the stylesheet of the widget does not change them: its rules apply only under the root class nmf-form or to nmf-* classes.

User Story 7 - Read option descriptions written in Markdown (Priority: P3)

The descriptions of Nixpkgs options are Markdown. They contain code spans, links and bare URLs. The form shows the description of each leaf below its control, with the Markdown rendered, so that the user does not read backticks and link syntax.

Why this priority: the description is readable as plain text, but the markup makes it difficult to read.

Independent Test: the description DOM tests and the unit tests of the Markdown parser mount descriptions and read the elements that the form makes. The screenshot check of the demo waits for a <code> element in a description.

Acceptance Scenarios:

  1. Given a description with a code span, When the form mounts, Then the code span shows as a <code> element below the control.
  2. Given a description with a link, an autolink or a bare http, https or mailto URL, When the form mounts, Then it shows as a link that opens in a new tab.
  3. Given a description with a blank line, When the form mounts, Then the text after the blank line starts a new paragraph. A single newline becomes a space.
  4. Given a description with raw HTML, When the form mounts, Then the HTML shows as literal text.

Edge Cases

  • An option has no default and is required: the form shows it as required, and validation (see 002-validation) reports it when it is absent.
  • A pattern from Nix uses syntax that is valid without the u flag but not with it: the validator accepts the pattern.
  • A schema contains an external $ref (not into #/$defs): the widget does not resolve it. [NEEDS CLARIFICATION: are external references out of scope, or must the embedder dereference them first? (#41)]
  • A tagged union has only one branch (module-schema emits a oneOf with one branch for an attrTag with one tag): the widget does not treat it as a tagged union. It does not seed the branch, keep settings across branches, or compare per branch. This is a decision: support for a one-branch union was less elegant than expected and is not necessary, and the user moves the settings by hand when the tags of an attrTag change (fediversity/nix-module-form#15, fediversity/module-schema#34, fediversity/fediversity#1575).
  • The value of a union or a nullable object is not valid for the schema (for example a second tag): the widget does not persist the remembered values of other branches, because such a value is not valid.
  • A description contains a block construct (a list, a fenced code block, a table or a heading) or emphasis: the widget does not parse it, and shows the markers as text. The Markdown parser is inline-only on purpose: the descriptions of the modules that the widget shows today contain no block constructs, and some contain flattened lists in which a pair of * characters is text, not emphasis.
  • A description contains a link with a scheme other than http, https or mailto (for example javascript:): the widget shows it as text, not as a link.
  • A description contains a MyST role marker (for example {option} before a code span): the widget removes the marker and keeps the code span.
  • An object section has a description: the widget does not show it. Only leaves show a description.

Requirements (mandatory)

Functional Requirements

  • FR-001: The widget MUST render a form from a JSON Schema draft 2020-12 document and a value, into an element that the embedder gives (mountForm).
  • FR-002: The widget MUST resolve references into #/$defs of the root schema.
  • FR-003: The widget MUST NOT write schema defaults into the value, except the default branch of a mandatory tagged union. It MUST show a default as a placeholder.
  • FR-004: The widget MUST recognize a nullable option in these spellings: a type array with "null" (scalar or object), and an anyOf with a null branch and one other branch. It MUST render it with a switch.
  • FR-005: The widget MUST recognize a tagged union: a oneOf with two or more branches, whose branches are objects with exactly one required property, with or without type: "object" on the union node. It MUST render it with a select of the branch names.
  • FR-006: On a switch of branch, the widget MUST keep each setting whose schema is equal in both branches, and MUST remember the other values of each branch while the form is open. The same applies when a nullable object is turned off and on.
  • FR-007: The widget MUST render additionalProperties objects as editable maps, and MUST validate their keys against propertyNames, also when propertyNames is a $ref.
  • FR-008: The widget MUST render booleans as switches.
  • FR-009: The widget MUST validate with AJV for draft 2020-12, with unicodeRegExp: false, so that pattern follows the JSON Schema specification.
  • FR-010: The widget MUST report every change through onChange with the value without synthetic fields.
  • FR-011: The widget MUST follow the theme of the host page (the data-theme attribute, then prefers-color-scheme) and MUST offer resolveTheme and setTheme. It MUST let the host set the primary color per theme (primaryColor).
  • FR-012: The widget MUST NOT take styles from, or give styles to, the host page outside its element, and MUST NOT need an icon font.
  • FR-013: The widget MUST accept an optional JSONForms uischema. Without it, the order of fields follows the schema.
  • FR-014: The widget MUST show the description of each leaf below its control, rendered as inline Markdown: code spans, links, autolinks, bare URLs and paragraph breaks. It MUST make elements, not markup (no v-html), so that raw HTML in a description stays text. It MUST make a link only for the http, https and mailto schemes.

Key Entities

  • Schema: a JSON Schema draft 2020-12 document that describes the options of a module. The root is an object schema; $defs holds shared definitions.
  • Value: the JSON configuration that the user edits. A key that is absent means the default of the module.
  • Nullable option: an option that is null (unset) or a value of one other schema.
  • Tagged union: an option whose value is an object with exactly one key, the name of the chosen branch.
  • Map: an object with free keys, each value of the same schema.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: The schemas that module-schema makes for the demo module render without errors, and each option has a control.
  • SC-002: A mount with an empty value and an untouched form reports a value that contains no default, except the default branch of a mandatory union.
  • SC-003: The unit and DOM tests for nullable options, tagged unions, retention across branches and maps pass in CI.
  • SC-004: The bundle contains no icon font.

Assumptions

  • The schema comes from module-schema or a generator with the same output shapes. The widget relies only on that output (constitution principle II).
  • The embedder gives a schema whose root is an object with properties. The demo wraps the output of optionsToSchema in such a root.
  • The browser supports ES modules.

Known Deviations

None.