Demo

The repository ships two self-contained demo pages that run the whole pipeline -- a NixOS-style options module in, a rendered form out -- with no panel, no API and no deployment backend behind it. The first shows the value diff, the second the schema-change layer on top of it.

Running it

nix run .#demo

That prints a URL (http://localhost:8000/ by default; pass a port as the first argument) and serves the built pages: the value-diff demo at / and the schema-change demo at /schema-changes.html, which link to each other.

Serving is required rather than a convenience. forms-island.js is an ES module, and a browser fetches <script type="module"> under CORS, which a file:// origin always fails; the entry also fetches its three JSON documents, which file:// likewise forbids. Opening the built index.html from the Nix store gives a blank page and a console error.

To build it without serving:

nix build .#demo

nix flake check builds it too, as checks.panel-forms-island-demo. That is a build-only check: it catches a demo entry failing to compile or a demo module failing to convert. The browser-driven check is a separate one -- checks.panel-forms-island-screenshots, which is where the images below come from.

What it shows

demo/module.nix  --module-schema-->  schema.json  --nix-module-form-->  the form

The schema is generated at build time by module-schema's optionsToSchema from demo/module.nix. Nothing is committed as a fixture, so the demo stays an honest end-to-end proof rather than a snapshot that quietly drifts from what the generator really emits.

demo/module.nix declares one option per schema shape the widget has a dedicated renderer for:

option Nix type renders as
enable bool a switch
domain str with a default a text field with the default as a placeholder
adminEmail nullOr str a "Configure / use the default" toggle over a text field
logLevel nullOr (enum [...]) the same toggle over a <select>
resources nullOr (submodule ...) the same toggle over a nested object
extraHosts listOf str an array control
secretFiles attrsOf str a map with a new-key input
deployment-method attrTag { ssh-host, local } a tag <select> with only the chosen branch's fields

Those three nullOr rows are not redundant: module-schema emits a different JSON Schema spelling for each, and the demo exercises all of them. See schema shapes.

deployment-method deliberately carries no default, so it is required, and its ssh-host branch's host is defaultless too. Clear that field and press Validate to see a missing required leaf reported.

Its two branches also demonstrate what survives a variant switch. They declare username identically and host differently (only local defaults it), so switching from ssh-host to local carries username across and leaves host empty -- a value only carries when both branches declare the property with a deep-equal schema node, which guarantees it still validates. Switching back restores the branch you left, for as long as the form stays mounted.

The diff

demo/value.json is the configuration loaded into the form; demo/baseline.json is what was last deployed. They differ deliberately, so all three highlight colors are visible on load:

  • logLevel is set in the value but absent from the baseline -- added.
  • domain differs between the two -- changed.
  • adminEmail is set in the baseline but absent from the value -- reverted.

Deploy is disabled whenever the form matches the baseline, driven by onDiffChange. Edit a field back to its baseline value everywhere and it greys out again. It deploys nothing; it just reports what it would have submitted.

Schema changes

The second page, /schema-changes.html, is about what the form does when the OPTIONS moved between the deploy that produced the baseline and now. It renders a second, deliberately small module pair -- demo/schema-changes/deployed.nix is what the configuration was last deployed under, demo/schema-changes/current.nix is what it is edited against -- through the same generator, so the diff on screen is a real generator output rather than a fixture. The two differ in one way per case the schema-change layer has to render:

option deployed as now reads as
domain str str, reworded changed -- a badge and a tooltip
adminMail nullOr str renamed adminEmail renamed -- an inline old -> new
logLevel ints.between 0 3 enum [ ... ] changed -- plus a read-only old field
legacyPort port gone removed -- a read-only old field in its place
metricsPort absent port added

The read-only fields are synthetic: the library injects them so a value the new control cannot hold is shown rather than dropped, and strips them again before the value reaches the host. The wire value at the bottom of that page carries neither.

demo/schema-changes/renames.json is the one input a structural diff cannot infer -- a rename and a remove-plus-add look identical -- so the module author maintains it, and computeSchemaChanges takes it alongside the two schemas. demo/schema-changes/entry.ts is the whole embedder side of this: one extra call, whose result is handed to mountDeploymentForm as schemaChanges.

The rest of the page

Toggle theme flips <html data-theme> and calls setTheme() -- the two steps a host page has to do together, since the library reads the theme once at mount. The page starts on the OS preference.

Wire value at the bottom is the live onChange payload: exactly what an embedder would submit. Watch it while editing to see that unset fields stay absent rather than being filled with their Nix defaults.

The source

demo/demo-entry.ts is the value-diff demo's embedder adapter, passed to package.nix as its entry (demo/schema-changes/entry.ts is the other page's, built the same way). It is worth reading as documentation: it is the same contract the fediversity panel implements in around 630 lines, at roughly 50 lines of code, because everything the panel adds is its own -- API calls, auth, hidden inputs -- and none of it belongs to this library.

The other pieces are the two index.html files and demo/demo.css (the host surface, which the pages must own because forms-island.css deliberately leaves .v-application transparent), the modules, and the JSON documents. nix/demo.nix wires them together.

Screenshots

This repository commits no image. The ones below are generated: nix/take-screenshots.py loads these pages in headless Firefox, drives the theme toggle, and takes one full-page capture per theme; nix/screenshots.nix compresses the result, and nix/docs.nix takes that directory as the site's assets. So they show the revision that built the site, and a renderer that stops emitting the decoration a page is captured for fails the build rather than publishing a picture of the loss.

Build them on their own with nix build .#screenshots.

Light Dark
The demo form in the light theme The demo form in the dark theme

The schema-change page is captured at a narrower width than the value-diff one: its subject is a handful of decorated fields, and a wider shot is mostly empty page.

Light Dark
The schema-change demo in the light theme The schema-change demo in the dark theme