Skip to content

Run a document's interactions, from the model to the browser - #44

Merged
einari merged 8 commits into
mainfrom
work/ui-interaction-20260923
Sep 23, 2026
Merged

einari merged 8 commits into
mainfrom
work/ui-interaction-20260923

Conversation

@einari

@einari einari commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Screenplay 4.17.0 can now describe what pressing a button does. This is Scene's half: carrying that through the model, deciding what it means in the engine, and running it in a browser.

The model

Model/Interactions/ and its hand-maintained TypeScript mirror, with Behaviors on every record a document can attach to. scene-model-shape.json covers all of it, so either side drifting fails its own build.

Actions carry an explicit kind discriminator rather than being told apart by their members, which is the house style for elements. navigate back and close dialog both carry no operand, so nothing about their shape distinguishes them - and reporting an action a renderer does not understand needs a name to report.

The engine

resolveBehaviors collects what a trigger runs, additively and deterministically: outermost first, declaration order within a level, an explicit order winning over both. Additive is what lets a module gate every destructive action beneath it without each screen cooperating. Deterministic because an intermittently wrong ordering is far worse than a consistently wrong one.

runActions sequences them against an ActionDispatcher. A declined confirm stops the sequence, because gating what follows is the entire point of it. A navigation stops it, because the screen the remaining actions were written for is gone. An action kind the engine does not know is reported and the sequence abandoned - never silently skipped, which is what lets a newer document be opened by an older renderer without pretending it worked.

All of this is pure functions over the model, so the rules are asserted without a DOM, and a renderer that is not React inherits them rather than reimplementing them. eslint enforces that the engine imports no React and touches no DOM.

The renderer

useInteractions resolves and runs; it decides nothing. Handlers exist only for triggers a document actually uses - an empty handler on every element would make everything look interactive and would stop clicks reaching whatever encloses it.

Handlers are handed to the component rather than applied to a wrapper, because only the component knows which node they belong on: on a button the button, on a table the row. A wrapper would have meant silently changing the layout of every interactive element.

Breaking change

UiProfile.DefaultSizeClass is now a TargetSizeClass, not a SizeClass.

A profile's target size and an arrangement's size class were the same two-valued enum, and they are not the same idea. An arrangement axis distinguishes whether it is cramped; what a target assumes is a coarser statement about the device, and Screenplay gives it three values - compact, regular, expanded.

Sharing the enum meant a document saying target size expanded - valid Screenplay, used in Screenplay's own documentation - threw on translation instead of rendering. Nothing could previously have meant a genuine matrix point either: Screenplay has no per-axis target size syntax, so only the two diagonal values were ever reachable.

A sample application is what found it, which is the argument for having one.

Verification

227 engine, 86 model, 56 React and 40 C# specifications pass; eslint is clean, including the layering rules. interaction-fixtures.json pins the ordering and sequencing rules as data - and writing it found two defects the unit specifications had missed, both fixed here.

The engine being platform-neutral was a convention held up by review. Two
invariants now fail the build instead:

I3 - engine/** and model/** may not import react, react-dom or the renderer,
and may not reach for window, document, location, fetch or their siblings. A
pure engine is what lets 'entering this screen runs that query once with these
parameters' be a unit test rather than a browser run.

I4 - no route-template literal in engine/** or model/**. The model says
'navigate to <Screen>'; deciding that means /invoicing/invoices belongs to a
renderer. The same literal in react/** lints clean, and that asymmetry is the
seam.

Both report an explanation rather than a rule name, because the fix is usually
'inject a seam', not 'delete the line'. Verified against deliberate violations
of every clause, and the existing tree is clean.

Documents the layering, the four seam consequences and all seven invariants, and
adds the glossary Scene shares with Screenplay.
The platform-neutral half of the interaction spine: what the compiler produces
and what a renderer consumes. Behavior, InteractionBinding, the four trigger
kinds and the ten action kinds, in C# and in the hand-maintained TypeScript
mirror, with the shared shape manifest updated so each side fails its own build
on a desync rather than only the other's.

Two things the model deliberately does not carry. A navigation names a screen
rather than an address, because turning that into a URL is a renderer's decision
and a non-web renderer has no URLs to turn it into. An interval is normalized to
seconds at conversion, so nothing downstream carries a unit or converts one.

A message is three explicit members - literal, localization key, binding -
rather than one string a consumer sniffs, because 'does this start with
$strings.' is exactly the rule that ends up implemented differently in every
renderer.

Behaviors attach on SceneElement, Screen, Form, Layout, ScreenTemplate and
DialogTemplate. On the element itself rather than a wrapper, since interaction
belongs to the thing being interacted with.

Continuations live on the action base so a new action kind stays an append.
The sequencing rules of an interaction - order, continuations, short-circuits -
now live in pure functions over the model. That is what makes 'a declined
confirm stops the sequence' a unit test instead of a browser run, and what lets
a non-React renderer inherit the semantics rather than reimplement them.

resolveBehaviors collects the bindings a trigger fires, additively and in a
deterministic order: outermost attachment first, declaration order within a
level, an explicit order winning over both. Additive is the rule that lets a
module gate every destructive action beneath it without each screen
cooperating - and determinism is why this is a pure function over an ordered
list rather than something assembled while rendering. An intermittent ordering
bug is far worse than a wrong one.

runActions sequences them against an ActionDispatcher, the seam a renderer
implements with one method per action kind. Anything that can fail returns an
outcome rather than throwing, so a failure is something to branch on with 'on
failure' instead of something that unwinds the interaction.

An action kind the engine does not know is reported as a finding and the
sequence abandoned - never silently skipped, which is what lets a newer document
be opened by an older renderer without pretending it worked.

Actions carry an explicit kind discriminator. The house style sniffs members,
but 'navigate back' and 'close dialog' both carry no operand, so nothing about
their shape tells them apart - and a finding needs a name to report.
The ordering and sequencing rules are the two most likely to drift, because both
are easy to restate slightly differently and neither is visible in a type. They
are now data rather than prose.

Writing the corpus immediately found two defects the unit specs had missed, which
is the argument for having it.

Resolution did not really order by containment at all - it preserved whatever
order the caller happened to pass. The unit spec only agreed with it because it
listed attachments outermost-first. An attachment now carries an optional depth,
so a caller walking a tree upwards from the element gets the same resolved order
as one walking downwards, rather than silently getting the reverse.

An unknown action kind was reported and then the sequence carried on. Reporting
it and continuing is close to the worst option available: it half-applies a
sequence whose gate may well have been the action this renderer could not
understand. It now abandons the sequence, which is what the fail-closed posture
said all along and what the single-action unit spec was too small to catch.

Unlike the other corpora in this repository this one currently has a single
consumer, because there is no C# interaction engine - the engine runs where the
interaction happens. That is recorded in the corpus itself so the asymmetry is
deliberate rather than an oversight waiting to be discovered.
A ui profile's 'target size' and an arrangement's size class were the same enum,
and they are not the same idea. An arrangement axis distinguishes one thing -
whether it is cramped - so it has two values. What a target assumes is a coarser
statement about the device, and Screenplay gives it three: compact, regular and
expanded.

Sharing the two-valued enum meant a document saying 'target size expanded' -
valid Screenplay, used in Screenplay's own documentation - threw on translation
instead of rendering. Reusing an enum because its first two values happened to
match is the kind of economy that reads as correct until the third value exists.

TargetSizeClass is that third value's home. Nothing consumed the profile's size
class as a matrix point, and nothing could have meant one: Screenplay has no
per-axis target size syntax, so the only reachable values were the two diagonal
ones. Mapping the coarse class onto the matrix is now a single explicit step
where an arrangement is evaluated, which is where the two vocabularies have to
meet and the only place that should know how.
Until now a document could declare what a click does and the model could carry
it, but nothing in a browser ran it. This is the last hop.

useInteractions resolves and runs; it decides nothing. Which bindings a trigger
runs, in what order, whether a guard lets them, and where a sequence stops all
stay in the engine, so a renderer that is not React inherits the same semantics
rather than reimplementing them - and so all of it remains testable without a DOM.

Handlers are attached only for triggers a document actually uses. An empty
handler on every element would make everything look interactive and, worse, would
stop clicks reaching whatever encloses it. For the same reason a handler that
runs stops propagation: a row inside a table where both declare a click would
otherwise run the table's binding twice.

Lifecycle triggers are the hook's because React is what knows when an element
mounts. Running unload and leave from the cleanup is what makes them reliable -
React guarantees it, a component remembering to call something on the way out
does not. An interval becomes a timer cleared on unmount, because a document
asking for something every minute means while it is on screen.

The scope carries the dispatcher and the enclosing attachments, and nests: a
scope inside another appends rather than replaces. That is what keeps the
additive rule true in a renderer, so a module's behavior is still in scope at a
button six levels down without the button knowing the module exists.

Handlers go to the component rather than onto a wrapper the renderer invents,
because only the component knows which node they belong on - on a button it is
the button, on a table it is the row. Wrapping would have meant a renderer
silently changing the layout of every interactive element.

The browser dispatcher reports what the host cannot do instead of ignoring it,
and an unavailable command reports failure so the document's own 'on failure'
runs. A command that never reached a backend must not look like one that worked.
Scene's own starter profile builder still took a matrix point, so the change of
vocabulary stopped at the model boundary. Both sides now say the same thing,
which is the whole reason the C# and TypeScript builders are kept in step.
@einari einari added the major label Sep 23, 2026
Requiring them meant every component rendered directly - in a specification, in
a gallery - had to invent an empty object to satisfy the type. That is a lot of
noise to prove something the renderer already guarantees, and it broke twenty
specifications in the components workspace that never go through the renderer at
all.

Absent now means what it should: this component was not rendered by the renderer,
so nothing is attached. Spreading it stays safe either way.
@einari
einari merged commit 81010a1 into main Sep 23, 2026
6 checks passed
@einari
einari deleted the work/ui-interaction-20260923 branch September 23, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant