Player Action Affordances Contract Review
The accepted Player Contextual Actions design gives links, state choices, and ephemeral component commands one numbered presentation surface. Build Week also needs mapped PathMX Actions—especially durable component Save—to appear in that surface without becoming Context Actions or bypassing normal forms.
This addendum defines the implemented built-in Action Affordance provider. It projects the
real generated submitter into the existing Player presentation candidate and
activates it through form.requestSubmit(submitter). Native validation, the
Player action store, Actor authority, Action Availability, pending/retry state,
and canonical paint remain owned by their existing modules.
The maintainer accepted this contract on 2026-07-15. Generated forms now carry exact scope and target metadata; separate Block and Source providers project their real submitters in the accepted order; and Player form-state events keep pending, rejected, and re-enabled affordances synchronized without duplicating submission state.
Decision Summary
- An Action Affordance is distinct from a browser Runtime Context Action.
- The inline form submitter is canonical; the floating Player pill is another projection of that same control.
- Providers contribute a small common presentation candidate. Provider-only DOM references and activation behavior stay behind a closure rather than a public discriminated union.
- Generated forms declare their exact action scope and target through
internal
data-pathmx-*attributes. - Activation calls the connected canonical submitter with
requestSubmit. It never constructsFormData, invokes a Context Action, or calls an Action endpoint directly. - Block Action Affordances appear after Block Context Actions. Source Action Affordances appear after Source Context Actions. Beat forms are outside the first slice.
- Pending or disabled affordances keep their slot. Rejected affordances become activatable again and keep the same slot.
Generated Form Contract
The existing SourceForm remains the Build-side instruction:
type SourceForm = Readonly<{
localAction: string
action: string
submitLabel: string
}>
Rendering adds exact integration attributes to the normal form:
<form
method="post"
action="/lesson.path"
data-pathmx-form="save"
data-pathmx-action="responses.save"
data-pathmx-action-scope="block"
data-pathmx-action-target="lesson.path#outlier-model"
>
<input type="hidden" name="__pathmx_action_scope" value="block">
<input type="hidden" name="__pathmx_action_target" value="lesson.path#outlier-model">
<!-- Block content -->
<button type="submit" name="action" value="save">Save</button>
</form>
The form also carries the reserved successful controls specified by Scoped Action Mapping Selectors so non-JavaScript and Player-enhanced submission resolve the same mapping.
The generated values are:
| Attribute | Block form | Source form |
|---|---|---|
data-pathmx-form | local Action Mapping key | local Action Mapping key |
data-pathmx-action | qualified Action ID | qualified Action ID |
data-pathmx-action-scope | block | source |
data-pathmx-action-target | stable Block ID | stable Source ID |
These attributes are generated output, not authored schema or authority. The server continues to derive the canonical target from trusted route, mapping, and Actor context. The normal-form enhancer also uses the scope and target attributes for its transient submission key instead of inferring a Block by walking outward from a wrapper form.
Every projected submitter must be a successful native submit control with
name="action" and a non-empty value. A form may contribute more than one
submitter later; the first slice's generated forms contribute one.
The contract permits only one generated form owner at a Source or Block scope. Build must diagnose competing form contributions rather than silently choosing one; this is not a provider-ordering problem.
Provider Interface
The implementation already benefits from a provider-owned activation closure. The final shared Player projection remains small:
type PlayPresentedActionSnapshot = Readonly<{
id: string
slot: number
label: string
disabled?: boolean
pending?: boolean
selected?: boolean
}>
type PlayActionCandidate = Readonly<{
/** Stable and unique within this provider for the current focus. */
id: string
label: string
disabled?: boolean
pending?: boolean
selected?: boolean
activate(): boolean
}>
type PlayActionProviderContext = Readonly<{
route: PlayRoute
block: PlayBlock
beat: PlayBeat
playActions?: PathMXContextActionRegistry
requestState(choice: PlayBeatStep): boolean
}>
type PlayActionProvider = Readonly<{
id: string
collect(
context: PlayActionProviderContext,
): readonly PlayActionCandidate[]
}>
The resolver prefixes the candidate ID with the provider ID, composes provider
results, and assigns slot = finalIndex + 1. React receives snapshots only.
The live link, Context Action owner, state choice, form, and submitter stay in
provider closures held by the Play Session.
pending is presentation state, not Action Run state. It mirrors the normal
form's transient Player state and implies disabled. selected remains for
state choices. The UI does not need or receive an underlying action type.
This provider interface is Player-internal and locally substitutable in tests. It is not an authored plugin schema, Graph field, or Literate Component API.
Action Affordance Provider
Two instances of one built-in provider collect normal generated forms:
function actionAffordanceProvider(
scope: "block" | "source",
): PlayActionProvider
The Block instance collects forms whose generated target equals the active Block ID. The Source instance collects forms whose target equals the active Source ID. Collection is limited to the current rendered Source root and preserves form DOM order, then submitter DOM order.
For each submitter:
- candidate ID is
${targetId}:${localAction}; - label resolves from
aria-label, visible text,value,title, then the local Action key; pendingis true when the form hasaria-busy="true"ordata-pathmx-action-stateissubmitting,awaiting-paint, ordelayed;disabledis true when pending, native form/submitter state disables the control, or the form is no longer current;- rejection is not pending and therefore makes the same slot activatable again; the existing inline error remains the detailed error surface.
The provider does not ask the browser Runtime Context Action registry for mapped Actions and does not expose Action input or authority to the Player.
Activation
The candidate closure captures the form, submitter, scope, target, and local Action observed during collection. On activation it verifies:
- form and submitter are still connected;
- submitter still belongs to that form;
- generated scope, target, Action ID, and local Action are unchanged;
- the form still belongs to the active rendered Source; and
- the form and submitter are not pending or disabled.
It then performs exactly:
form.requestSubmit(submitter)
return true
requestSubmit deliberately preserves constraint validation and the native
submit event. The existing normal-form enhancer receives that event, builds
the payload, and submits through PlayerActionStore.
Provider Ordering
The final first-slice order is:
- Beat Context Actions
- Beat state choices
- Beat links
- Block Context Actions
- Block Action Affordances
- Source Context Actions
- Source Action Affordances
This keeps immediate Beat controls on the lowest learned keys, then widens
through Block and Source scope. A Block Save appears beside the learning move
it saves. A Source-wide Submit work remains last. Adding the provider does
not turn a mapped Action into a Context Action.
Within one form, disabled and pending controls remain in their declared place. Slots are reassigned only when the active focus or available control set changes, not when lifecycle state changes.
Durable Component Save Scenario
The active Beat contains an outlier component. It publishes ephemeral
Decrease and Increase Context Actions. Its Block maps save to
responses.save. The Player shows:
DecreaseIncreaseSave
Activating Save calls the generated form's real submitter. While the request
is pending, slot 3 remains visible and disabled. If validation or the server
rejects the response, slot 3 becomes active again and the inline form error
explains why. When canonical paint replaces the Block, the provider collects
the replacement form and the component rehydrates from ctx.responses.
The component never calls ctx.play.actions.set for Save. It may use that API
for Decrease and Increase because those commands change only local draft
state.
Refresh And Lifecycle State
The normal-form enhancer already writes:
data-pathmx-action-state;aria-busy; and- inline status or error content.
It must also emit one internal bubbling pathmx:action-form-state event after
the rendered form state changes. The Play Session listens while active and
recomposes only when the event's form belongs to the active Source or Block
scope. This keeps the Action Affordance slot synchronized without coupling the
Play Session to PlayerActionStore.
DOM replacement remains handled by the existing Play route observer. A rejected or pending state update does not rebuild the Play Route; it only recollects presentation candidates.
Errors And Stale State
| Condition | Player outcome |
|---|---|
| malformed generated scope/target | omit candidate; report a development diagnostic |
| submitter detached or changed before activation | return false; next projection removes or replaces it |
| native validation fails | activation is handled; browser/form validation owns feedback; no request |
| form is pending | retain disabled slot; activation returns false |
| server rejects | existing inline Action error; same slot re-enables |
| causal paint replaces form | collect replacement form; stale closure cannot activate |
| Actor authority or Action Availability changes | server remains authoritative; normal rejection/refresh path applies |
The Player does not create a second toast, pending store, retry policy, or Action error model.
Verification Contract
Focused tests must cover:
- exact Block/Source generated scope and target attributes;
- normal-form submission keys using those attributes;
- Block and Source provider collection, DOM order, labels, stable IDs, and exclusion of forms outside the active Source;
- the seven-part provider order above;
- activation through the real submitter, including native validation and one normal submit event;
- no Context Action registration or invocation for a mapped Action;
- detached, retargeted, disabled, and pending controls;
- pending → rejected → retry and pending → canonical-paint transitions without slot movement;
- inline and floating activation producing the same form payload and
PlayerActionStorelifecycle; and - the durable component response end-to-end scenario.
Maintainer Decision
Accept or revise this exact additive contract:
- generated
data-pathmx-action-scopeanddata-pathmx-action-target; - the common candidate/snapshot shapes including
pending; - provider-owned activation closures rather than a public augmentation union;
- separate Block and Source Action Affordance providers in the seven-part order above;
form.requestSubmit(submitter)as the only Player activation path; and- one internal form-state event to refresh pending/rejected projection, with scoped Action Mapping transport accepted as the normal-form prerequisite.