Component gallery
Every frame on this page is a live form, not a screenshot. It is produced by
RequestType::form — the same function behind every form the factory serves —
from a manifest you can read, and it is regenerated and diffed in CI on every
change to the renderer.
That matters more than it sounds. A gallery of hand-written HTML is a lie the
first time the renderer changes, and a lie about what a form looks like is the
most convincing kind, because nobody diffs a screenshot. So none of this is
drawn: cargo run --example gallery walks the field kinds, loops over the
built-in themes, and writes what you see below.
They are static pages. Clicking Submit demonstrates the HTML5 constraint layer — the browser refuses to navigate while a required field is empty, and announces why to a screen reader — and nothing else. Everything else works: type in the fields, tick the boxes, watch the conditional ones appear.
Use the palette buttons to switch themes. The frames follow this site's own light/dark setting, so what you see is what a reader with your preferences would get.
Every field kind
Ten kinds, one of each, plus the two acknowledgments that follow them. The help text under each label says what that kind enforces — which is a real answer, not a description, because validation is one function run at both ends: the browser's check is a convenience and the server runs the same rules again.
Two things worth looking at rather than reading about. The file field is not an input here: it renders as a note saying the upload comes after verification, because uploads happen once an address is proven and the public form has no file input at all. And every label is set in the theme's mono stack — a field label is a name for a value, which is closer to code than to prose, and that one choice is most of why a generated form reads as designed.
The manifest · its JSON Schema
When a submission is rejected
The same form, re-served with a deliberately bad submission on it. The
messages below came out of the validator, not out of a designer's
placeholder text — the gallery builds a wrong submission, runs
validate_at(…, Phase::Submit) on it, and renders whatever comes back.
The summary at the top and a message beside each field, deliberately: the summary is what a screen reader announces on load, and the per-field message is what a sighted reader needs while fixing it.
Note what is not flagged. reference_code holds nh-301, which does not
match the field's pattern — and there is no error on it, because a pattern is
enforced by the browser and never by the server. The server enforces the cap
and the type. If you are writing a second client, that distinction is the kind
of thing to get from this page rather than from a surprise.
Note also supporting_document: a urlencoded string aimed at a file field is
somebody probing, so it is refused rather than ignored.
Conditional fields
Change the format, tick the travel box, put a large number in attendance. Each
of those reveals a field, and each rule is a show_when in the manifest that
the server re-evaluates on submit — a client-side check is a convenience
and never a control.
The form starts nearly empty, which is correct rather than broken: none of the conditions hold yet. Turn JavaScript off and every conditional field is simply shown — the safe direction, since a visible optional field is a question you can ignore where a hidden required one is a form that cannot be submitted and does not say why.
The rule that surprises people: a field is required only when it is visible. The browser did not show it, so the server cannot insist on it.
A form in steps
Multi-step is server-side — one page per step, a POST between them — so it works with JavaScript off and survives a closed tab.
The other two: what you're asking and travel — the last of which only exists when the format is not remote. A whole step, skipped. A field on a hidden step is not required however it is declared, because a record carrying a field the browser never showed is a record that differs from what was submitted.
The upload page
The one place a file input exists. A submitter reaches it after clicking the link in their verification mail, and by then every visibility question has already been answered by values that are no longer editable.
Submitting with nothing chosen is how an optional attachment is declined — absence is the decline, so there is no separate skip button to fall out of sync with the validator. The accepted kinds and the cap are stated under the input, and both are enforced against sniffed magic rather than a claimed media type or a filename extension.
Booking a time
kind = "booking" changes the flow rather than adding a field. The page shows
a week of slots, a submission names one, a soft hold starts, and the
verification click converts the hold into the booking. One manifest, two
readers: [availability] is home's — the slot pipeline runs it — and
[policy] is the box's.
Everything here works with JavaScript off: the slots are radio inputs, week
paging is links, and the times are server-rendered in the host's zone and
labelled as such. booking.js is enhancement only — it re-renders the times in
your zone (which is why the label above probably does not say New York) and
adds the duration filter. Nothing a submission depends on lives in it.
The slots are real. They come out of the availability engine run against the
starter's own [availability] — Tuesday and Thursday afternoons, Wednesday
mornings, 30 and 60 minutes, a 10-minute buffer, 24 hours' notice, three
meetings a day — with two commitments already on the calendar. That is why
Tuesday is missing its 7pm and why some days are shorter than their window: a
hand-drawn week would show a layout no policy produces.
The box never computes availability. Home pushes slots as data and the box can only narrow them — by holds and bookings it knows about, and by the past. A public server that could compute your free time would need your calendar.
booking_page accepts values and errors and renders them, but both server
callers pass the defaults, so a failed POST currently gets the week back with
a one-line notice instead. The rejected
view is what that path
is growing into. Rendering it early is how two gaps became visible: its summary
lists raw field names where an ordinary form's lists labels, and no slot
comes back selected — so a visitor who mistypes their address would lose the
time they picked.
Asking a group about times
A times poll is the same weekly frame over seeded candidates, each a tri-state
answer. Seeded, never a blank 7×24 grid: what a participant is shown is only
what the organizer could actually offer.
Two rows look identical on purpose. An unanswered candidate renders as "no" — when2meet's rule, where you paint the times you can do and silence is unavailability. It is worth knowing that the default is a real answer rather than an absence, because it means a participant who opens the page and closes it has said something.
The yes-counts beside each time are server-rendered. A times poll is names, capabilities and three words — never an address and never prose — which is what lets the page be shown to strangers at all.
The survey
Times are one question kind out of six. A general poll is a list of typed questions, and the two frames below are the same specimen survey at the two moments that matter: before your vote, and after the poll closed.
Two of six people have answered, Priya's own two answers are already on the
form, and the page says what it owes her: this poll is show = "after_vote", so
"Results appear after you answer." It also states the anonymity it can
actually deliver — "your name is not shown with results… (Written answers
still read like their author.)" — which is the sentence a course-eval tool
usually leaves out.
Grab the ranking and drag it. The row has a grip, and pointer capture keeps
the stroke alive when you leave the row; touch-action: none sits on the grip
alone, so the page still scrolls under a thumb everywhere else. Then tab into
the same list: ↑/↓ buttons are the keyboard's path, and the grip is
aria-hidden, so the drag adds a way to answer rather than becoming the only
one. The VAS behaves the same way — a slider laid over a number field, holding
back its thumb until you touch it.
Underneath both is a form that works with the script blocked: ranking degrades
to a <select> per option (—, 1, 2, 3) and the VAS to the number field
itself. That is the version the server accepts either way; the widgets are the
layer on top.
The specimen renders in a third state the real page never uses — script on,
network off. A served page autosaves as you answer and polls its results; a
golden file that did either would answer "Couldn't save — use the button
below" the moment you dragged a row, and poll a 404 every ten seconds. So the
form carries data-demo="1" and everything that reaches the server is switched
off. Everything you can feel is the real code.
Four things here are the design rather than the styling:
- Nothing is preselected but Priya's own two answers. The VAS field is empty, and an untouched one submits nothing — because a slider parked at 50 that nobody moved would enter the data as a real opinion. Absent is absent, all the way into the widget.
- "Ranking only some is fine" is on the page, and it is true: a partial ranking is a legal ballot rather than an error, and the tally counts it as far as it goes.
- The
likertitem labels every point, which is what makes it a Likert item rather than a numbered row. The alternative shape, anchors at the ends only, is the other legal spelling and never both at once. - The promise line is rendered from the policy, not written by hand. Who sees the results and whose name rides them are decided before the vote and fixed at creation, so the sentence on the page is the same object the server enforces.
The closed page is what the link in someone's mail is still good for a month later. It carries the organizer's resolution at the top — "Replication it is" — because a poll whose links answer "so what happened?" is the difference between a decision and a form.
The numbers below it are not invented. The gallery runs five specimen ballots
through the same pure tally functions the box and factory-publish run, so the
ranking question shows real instant-runoff rounds — "Round 1: Replication
study 2 · Reanalysis 2 · Novel study proposal 1 — Novel study proposal
eliminated", all the way to a winner — and the likert question leads with the
distribution and the median, which is the honest summary for ordinal data.
Scroll to the text question and both readings of prose are there at once: the
word cloud that would go on a projector (coding 3 · walkthroughs 2) and, below
it, the sentences themselves.
Those sentences are here because this poll is identity = "named" — so the
voters are disclosed, and each answer carries its author. Switch that one word
to anonymous and three things change at once: names never render, the CSV
export goes nameless, and under three respondents the breakdown is withheld
entirely — the suppression floor, asked as
the same question of the same number by every emitter.
The ranking here has no grip and the VAS no slider, which is not an oversight: a closed poll is read-only, and the script returns early the moment it finds no submit button. Nothing is draggable because nothing is answerable.
The projector
The same five ballots, read the way a wall has to read them. This is the page
behind live polls on a slide — a browser window on the
second display, an <iframe> in a web deck, or the PowerPoint add-in's frame
all load exactly this.
Three differences from the participant's page, and all three are one decision — a projector is an audience, mid-poll:
- Nobody's name is anywhere, even though the ballots carry them. "Who answered what" is a participant's disclosure, never the room's.
- The ranking shows first preferences alone. No runoff rounds, because rounds computed on a partial electorate imply a winner that one more ballot can flip — and the wall is the worst place to publish a result that moves.
- The text question projects as a cloud, then says so in words: "3 written
answers — the full text stays on the presenter's screen, not the wall."
codingandwalkthroughsreach the wall because two different ballots each chose them; a word one person used cannot render, which is the guard that replaces a profanity list. The sentences live inpolls statusand the TUI's/pollsinstead.
The join URL is printed large across the top because this specimen is a link
poll. A roster poll's screen shows the counts without one — its doors are
personal, so there is no address to put on a wall.
This frame does not move, and the served one does: screen.js refreshes every
two seconds, and a golden file can't. The page is built to answer identically
without it, one reload behind, which is the same enhancement contract as the
form above.
The generator matches QuestionKind exhaustively and then asserts the
specimen actually contains one question of each general kind. Adding a seventh
kind stops the gallery compiling, and then fails it until a real question of
that kind is on this page. A gallery missing a kind is worse than no gallery,
because it reads as a complete list.
One gap this page does not close yet: the survey's manifest is not exported
beside the other source/
files, so the spec above is quoted on the polls
page rather than linked as bytes. That is
the same condition — prose here, no artifact — this gallery exists to remove.
The shipped starters
Request types are data, not code: copy one, edit the fields, and the form, both
validators, the schema and the tool declarations regenerate. These five ship
with mecha-manifest, and they render through the path everything above does —
a new starter appears in this gallery without anyone adding it here.
book · lab-application · letter · meeting · speaking
Why there are two palettes
A schema-driven form should look designed without anyone designing it, and certainly without an agent designing it — a model that writes CSS per request produces a different form every time, which is the opposite of a surface people learn to trust. So the layout is fixed and shared, and a theme is tokens, never rules: nine role-named custom properties, and no selectors.
paper exists to prove that. One built-in theme is a stylesheet with extra
steps; a second, deliberately unlike the first, is what shows the structural
sheet hardcodes nothing. Switch any frame above between the two — if a violet
edge or a dark ground survived into paper, a colour would be written
somewhere it should not be.
The palette buttons are built from the gallery's own
index.json, so a theme added in
mecha-factory appears here without anyone editing this page.
Where this comes from
git clone https://github.com/ljchang/mecha-factory
cd mecha-factory
cargo run --example gallery -- gallery
xdg-open gallery/index.html
The output is committed at gallery/, and CI regenerates it and refuses a
diff. That is the same shape as a golden-file test, on purpose: a change to the
renderer or to a palette arrives as a reviewable diff of the rendered HTML,
which is the review a form generator deserves.
The gallery's clock is a literal, and it has to be. A committed golden file
rendered against the real clock would differ from itself every day and the
drift check would cry wolf until somebody deleted it. So the booking pages are
rendered at a fixed instant — a Monday morning in the starter's own timezone,
chosen so its 24-hour minimum notice lands inside the first rendered week. The
chrono crate reads no clock here; the caller supplies now, which is what
makes that possible at all.
Two guards keep it honest as the crate moves. The generator matches FieldKind
exhaustively, so adding a variant stops it compiling and then fails until a
real field of that kind exists — a gallery missing a kind is worse than no
gallery, because it reads as a complete list. And themes come from the built-in
array rather than a list written by hand, which is what makes the sentence above
about a third palette true rather than aspirational.
The whole directory also passes the publish gate — the same check a bundle faces, which refuses any external reference. Every page you have scrolled past fetches nothing.