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:
logLevelis set in the value but absent from the baseline -- added.domaindiffers between the two -- changed.adminEmailis 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 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 |
|---|---|
![]() |
![]() |



