Repository navigation
Edit the field where it sits, and draw it as it will look - #1
Merged
Merged
Conversation
The canvas was a list of *descriptions* of fields — an icon, the label as static text, the type name underneath — and everything you could change lived in a pane on the right. So the two things you do most, writing the question and writing what goes in the box, happened at arm's length from the thing they belong to, and the canvas never answered the question you open a form builder to ask: what will this form look like? Now the card draws the control and its text is editable in place. Click the question to rewrite it, click the box to set its placeholder, click an option to rename it, and use + Add option where the options actually are. Required is a switch on the card instead of a badge that told you the state and made you go elsewhere to change it. Logic, validation and prefill stay in the inspector, because those are properties *of* a field rather than parts of it. This is not a second renderer, and two things keep it from becoming one. `builder.css` is registered with `form.css` as a dependency, so every front-end class is already in the document; emitting `.atf-input` and `.atf-choice__input` inside an `.atf-form` means the theme's own radius, borders, spacing and label position apply because it is the same stylesheet. The token values come from the server's own render — one request per theme change, its `<style>` block lifted and repointed at the canvas — rather than from a second resolver in TypeScript, which is the twin-engine problem the logic and calculation code works hard to avoid. And the map is type → *shape*, not type → markup: thirty-seven types share about a dozen visual shapes. `field-preview.test.ts` asserts every registered type has one, since an unmapped type falls back to a text box and a signature pad drawn as a one-line input is a preview that lies. The elaborate controls — Likert, signature, rating — say what they are instead, because a preview that is confidently wrong is worse than one that is visibly a summary. One flaw found while testing this, worth naming because it was invisible: a newly added option started blank, and `atf_normalize_choices()` drops a choice whose label and value are both empty. So it looked fine and then vanished a couple of seconds later when the autosave came back, with nothing to explain why. New options are named now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119U4sRRWGcTQdwYreTwpAp
Two bugs, both of which made the inline fields look simply un-typeable. **Clicking a label threw the caret away.** `selectField()` called `renderCanvas()`, which rebuilds every card — and now that a card contains the field's own editable label, that destroyed the element the click had just put the caret into. Selection changes no structure, so it rebuilds nothing now: two class toggles say the same thing, keep the caret, and are quicker besides. **Anything typed after an autosave was silently discarded.** A save replaces `this.schema` with the server's normalised copy, so every card — which closed over the field object it was rendered from — was writing to a detached one. The autosave fires 2.5 seconds after the last keystroke, which is precisely when somebody pauses mid-sentence and then carries on; the rest of the sentence went into an object nothing serialises and disappeared at the next render, with no error anywhere. Deferring the rebuild was the first thing I tried and it made it worse: it keeps the stale binding alive for longer. The fix is to stop depending on object identity at all — `PreviewHandlers` now hands the caller the *live* field, resolved by id at write time, so the number of times the schema has been replaced stops mattering. Choice rows resolve by index into that live field for the same reason. Verified against a live form: type, pause five seconds for the autosave to land, keep typing, blur — the whole label reaches the server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119U4sRRWGcTQdwYreTwpAp
They edit the same values, so a label rewritten on the card while the Label box beside it still shows the old text reads as one of them being stale — with no way to tell which one is lying. Both directions now write through to the other. Inspector controls that have a canvas twin carry a `data-atfb-bind` tag, and each side updates the other's value rather than re-rendering: a full rebuild works and is what a *structural* change already does, but doing it per keystroke recreates every shell component in the pane sixty times a second to change one string. The sync is deliberately one-directional per edit and value-only — firing the other pane's handlers would put the two in a loop, each telling the other about a change it had just made. Canvas → inspector was missing entirely. Inspector → canvas worked for the label because `update()` repaints the canvas, but not for choices: that editor mutates in place and only marks the form dirty, which was invisible until the canvas started drawing the options. **A pre-existing bug this uncovered.** The choices editor guarded its value-follows-label behaviour with `choice.value === choices[ index ].value` — the same object compared with itself, so it was always true and the value followed the label unconditionally. That is precisely what the comment beside it says must not happen: an entry is stored against the *value*, so rewriting it orphans every submission already recorded under it. The guard reads the label before the assignment now. The canvas mirrors the label verbatim rather than slugifying it, which is what the inspector and `atf_normalize_choices()` already do. Two spellings of "the value follows the label" would have disagreed the moment somebody used both panes on one field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119U4sRRWGcTQdwYreTwpAp
AllTerrainDeveloper
pushed a commit
that referenced
this pull request
Sep 16, 2026
On a phone the shell forces the builder full-screen at ~360px and the window still laid itself out for a desktop: the folded rails left a 28px sliver painted over the canvas (a 0px track does not zero the item's own padding), the canvas list's implicit auto track sized itself to a card's min-content so every card ran off the right edge, the tab strip clipped its last tab, and the toolbar ran to 885px with Save cut off. A tap on a palette chip added nothing, because the field was only ever added through the drag manager's click-only callback and the shell refuses every drag on a phone by returning null from start(). - Fold a rail's padding and border with its track; the rail rules now sit above the container queries, since they win only by coming later. - Give the canvas list an explicit minmax(0, 1fr) column, and make the card preview the `atf-form` container form.css asks about, so composite fields and half-width rows fold in a card at the width they fold on the published form. - Scroll the tab strip sideways and wrap the toolbar icon-only (labels stay for assistive tech) below 640px. - Let a chip's own click add the field when the manager declined the press — which also makes keyboard activation work. - Below 640px the inspector takes the whole width and carries Done; the palette opens as a full-width sheet from an "Add a field" button under the canvas, closes on a tap, and is exclusive with a selection. - Raise the builder's own inputs to 16px under the mobile stamp, per the shell's mobile contract (the card preview keeps the theme's size). Tests pin the chip tap under a declining manager and the stylesheet's cascade order; both were watched failing against the old code. Fixes #1. Upstream: #55. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The canvas was a list of descriptions of fields — an icon, the label as static text, the type name underneath — and everything you could actually change lived in a pane on the right.
So the two things you do most, writing the question and writing what goes in the box, happened at arm's length from the thing they belong to. And the canvas never answered the one question you open a form builder to ask: what will this form look like?
What changed
The card now draws the control and its text is editable in place:
+ Add optionsits where the options are.Each control looks like itself: radios are radios, checkboxes are checkboxes, a dropdown has a chevron, a paragraph field is a taller box.
Logic, validation and prefill stay in the inspector, as asked — those are properties of a field rather than parts of it.
Why this is not a second renderer
A builder that draws its own approximation of a control is a builder that drifts from what it ships. Two things prevent that.
The stylesheet is the real one.
builder.cssis registered withform.cssas a dependency, so every front-end class is already in the document. Emitting.atf-inputand.atf-choice__inputinside an.atf-formmeans the theme's own radius, borders, spacing and label position apply because it is the same CSS, not a copy of it.The token values come from the server's own renderer. One request per theme change; its
<style>block is lifted and its selector repointed at the canvas. Resolving a form's theme again in TypeScript would be a second answer to "what colour is this" — the same twin-engine problem the logic and calculation code goes to some length to avoid.The honest limits
The map is type → shape, not type → markup: 37 types share about a dozen visual shapes.
field-preview.test.tsasserts every registered type has one, because an unmapped type falls back to a text box, and a signature pad drawn as a one-line input is a preview that lies.The elaborate controls — Likert, signature, rating, scale, repeater — render as a labelled summary saying what they are and pointing at the inspector. A preview that is confidently wrong is worse than one that is visibly a summary.
Bugs found while building this
Three, all of which would have shipped silently.
A blank new option disappeared.
atf_normalize_choices()drops a choice whose label and value are both empty, and the schema is normalised on every save — so a freshly added option looked fine and then vanished a couple of seconds later when the autosave returned. New options are named.Anything typed after an autosave was discarded. A save replaces
this.schemawith the server's normalised copy, so every card — which closed over the field object it was rendered from — was writing to a detached one. The autosave fires 2.5s after the last keystroke, which is exactly when somebody pauses mid-sentence and carries on.PreviewHandlersnow hands the caller the live field, resolved by id at write time. (Deferring the rebuild was my first attempt and made it worse: it keeps the stale binding alive longer.)A pre-existing guard that never fired. The inspector's choices editor guarded its value-follows-label behaviour with
choice.value === choices[ index ].value— the same object compared with itself. Always true, so the value followed the label unconditionally, which is exactly what the comment beside it says must not happen: an entry is stored against the value, so rewriting it orphans every submission recorded under it.Testing
tscclean, 0 PHPCS errors.Worth a reviewer's eye
contenteditableover<input>— the label has to wrap, sit on the theme's line-height and inherit its font.plaintext-onlykeeps pasted markup out.editupdates the model and mirrors to the inspector without re-rendering (so the caret survives);restructuresnapshots and re-renders, and is used only when the shape changes.