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:
- 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.
- Given a stored value, When the form mounts, Then each control shows the stored value.
- Given a mounted form, When the user changes a control, Then
onChangereceives the complete new value, without synthetic fields of the widget. - Given a schema that uses
$refinto$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:
- 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. - 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 throughonChange.
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:
- Given a nullable option in any supported spelling, When the value is
null, Then the switch is off and no inner control shows. - Given the switch is off, When the user turns it on, Then the inner control shows and the user can enter a value.
- 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.
- 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:
- Given a tagged union, When the form mounts, Then a select shows the active branch and the controls of that branch.
- 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.
- 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.
- 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:
- Given an
attrsOfoption, When the user adds an entry with a name, Then the value holds the new key. - Given a
propertyNamesschema that is a$refinto$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:
- Given the root element has
data-themeset tolightordark, When the form mounts, Then it uses that theme. Given no attribute, Then it followsprefers-color-scheme. - Given a mounted form, When the host calls
setTheme("dark"), Then the form changes to the dark theme. - Given a host page with global
.controlor.inputrules, When the form mounts, Then the rules do not apply to the controls of the widget. - Given a host that passes
primaryColor, When the form mounts, Then the inputs and switches use that color. Given noprimaryColor, Then they use the default. - 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-formor tonmf-*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:
- Given a description with a code span, When the form mounts, Then the code span shows as a
<code>element below the control. - Given a description with a link, an autolink or a bare
http,httpsormailtoURL, When the form mounts, Then it shows as a link that opens in a new tab. - 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.
- Given a description with raw HTML, When the form mounts, Then the HTML shows as literal text.
Edge Cases
- An option has no
defaultand is required: the form shows it as required, and validation (see002-validation) reports it when it is absent. - A
patternfrom Nix uses syntax that is valid without theuflag 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
oneOfwith one branch for anattrTagwith 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 anattrTagchange (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,httpsormailto(for examplejavascript:): 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
#/$defsof 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
typearray with"null"(scalar or object), and ananyOfwith a null branch and one other branch. It MUST render it with a switch. - FR-005: The widget MUST recognize a tagged union: a
oneOfwith two or more branches, whose branches are objects with exactly one required property, with or withouttype: "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
additionalPropertiesobjects as editable maps, and MUST validate their keys againstpropertyNames, also whenpropertyNamesis 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 thatpatternfollows the JSON Schema specification. - FR-010: The widget MUST report every change through
onChangewith the value without synthetic fields. - FR-011: The widget MUST follow the theme of the host page (the
data-themeattribute, thenprefers-color-scheme) and MUST offerresolveThemeandsetTheme. 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
descriptionof each leaf below its control, rendered as inline Markdown: code spans, links, autolinks, bare URLs and paragraph breaks. It MUST make elements, not markup (nov-html), so that raw HTML in a description stays text. It MUST make a link only for thehttp,httpsandmailtoschemes.
Key Entities
- Schema: a JSON Schema draft 2020-12 document that describes the options of a module. The root is an object schema;
$defsholds 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 ofoptionsToSchemain such a root. - The browser supports ES modules.
Known Deviations
None.