Skip to content

Edit the field where it sits, and draw it as it will look - #1

Merged
AllTerrainDeveloper merged 3 commits into
mainfrom
inline-field-editing
Aug 15, 2026
Merged

AllTerrainDeveloper merged 3 commits into
mainfrom
inline-field-editing

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Aug 15, 2026 •

Copy link
Copy Markdown
Owner

The builder canvas rendering each field as it will appear, with its text editable in place

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:

  • Click the question to rewrite it.
  • Click the box to set its placeholder.
  • Click an option to rename it, and + Add option sits where the options are.
  • Required is a switch on the card, not a badge that told you the state and then made you go somewhere else to change it.
  • The inspector stays in step while you type, in both directions.

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.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 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.ts asserts 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.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.5s after the last keystroke, which is exactly when somebody pauses mid-sentence and carries on. PreviewHandlers now 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

  • 173 Vitest (7 new), 465 PHPUnit, tsc clean, 0 PHPCS errors.
  • Verified against a live form with real keystrokes: the caret survives typing; the inspector's Label and Choice boxes update as you type on the canvas, and the reverse; a label edit made after an autosave reaches the server; adding and removing options round-trips; the dependent card's condition chip follows a renamed question.

Worth a reviewer's eye

  • contenteditable over <input> — the label has to wrap, sit on the theme's line-height and inherit its font. plaintext-only keeps pasted markup out.
  • Two write paths — edit updates the model and mirrors to the inspector without re-rendering (so the caret survives); restructure snapshots and re-renders, and is used only when the shape changes.
  • Known gap, not addressed here: changing a choice's value can orphan a conditional-logic rule that references the old one. That predates this PR — the inspector has always allowed it — but inline renaming makes it much easier to reach. Worth its own change.

Daniel Lopez and others added 3 commits August 15, 2026 19:14
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
AllTerrainDeveloper merged commit 0c68b61 into main Aug 15, 2026
3 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the inline-field-editing branch August 15, 2026 17:35
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant