Learn / Who changes state

Messages carry intent, not results worked out from the last frame

A view draws from the Model it was given, and Foldkit draws on the next animation frame. Input faster than a frame (a fast typist, a double click, a key pressed as a field opens) meets the view as it was last drawn, not as the Model is now. A Message the view worked out from that drawing carries a stale result, and update applies it as if it were current.

So a Message says what the user did, and update works out what it means from the Model as it is then:

Instead ofSendAnd update
the sort order a click leads to (Sorted({ sort }))the column clicked (Sorted({ column }))toggles the Model's sort: Sort.toggle(model.sort, column)
an edit started on the key typed (EditStarted({ draft: key }))the key (EditTyped({ text }))starts the edit, or adds to the one open on that cell
the place a dragged column lands, worked out as the pointer movedthe pointer's offset, and the release (ColumnDragEnded)works out the drop from the columns as they are at release
the menu item under the keyboard, as drawnits index (MenuChosen({ index }))reads the item from the menu as it stands
the draft a form submits, as drawn (RequestedTodo({ title: model.draft }))the submit (DraftSubmitted())reads model.draft

Each of the first rows was a bug before it was a rule. Typing "Dowel" faster than the editor opened left "l": each key restarted the edit. The todo app added nothing when Enter came within a frame of the typing: the form sent the draft it was drawn with, still empty. Two clicks on a sort header inside one frame both sent "ascending". A drop worked out while the pointer moved would land by widths a resize had since changed.

The same applies to focus: a click on an open menu's own button moved focus to the button, the menu closed on losing focus, and the click, drawn a frame later, opened it again. Close a menu on focus leaving the element that holds both the menu and its button.

Test it with the frames held. Frames.track() (foldkit-mixins/testing) tracks the page's animation frames; its hold() keeps them back, so every event a test sends meets the view as last drawn, and release() lets them run. A test that waits for each change to show cannot see this class of bug at all. A sketch; examples/registry/test/page.test.ts has it in full:

ts
const frames = Frames.track()
frames.hold()
click(sortButton())
click(sortButton())
frames.release()
// Descending: the update toggled twice, from the Model as it was each time.

The rule

A setter is not unsafe — it is intentionally powerful infrastructure. What matters is authority: Sync may install a checkpoint because that is the job of its writable projection; a click handler must not use the same mechanism to bypass application Message semantics. When reviewing code, ask whether the write causes a transition or installs an already-decided value, and require the matching path.