Run a document's interactions, from the model to the browser - #44
Merged
Merged
Conversation
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.
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.
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.
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, withBehaviorson every record a document can attach to.scene-model-shape.jsoncovers all of it, so either side drifting fails its own build.Actions carry an explicit
kinddiscriminator rather than being told apart by their members, which is the house style for elements.navigate backandclose dialogboth 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
resolveBehaviorscollects what a trigger runs, additively and deterministically: outermost first, declaration order within a level, an explicitorderwinning 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.runActionssequences them against anActionDispatcher. A declinedconfirmstops 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.
eslintenforces that the engine imports no React and touches no DOM.The renderer
useInteractionsresolves 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.DefaultSizeClassis now aTargetSizeClass, not aSizeClass.A profile's
target sizeand 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;
eslintis clean, including the layering rules.interaction-fixtures.jsonpins the ordering and sequencing rules as data - and writing it found two defects the unit specifications had missed, both fixed here.