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

  1. An Action Affordance is distinct from a browser Runtime Context Action.
  2. The inline form submitter is canonical; the floating Player pill is another projection of that same control.
  3. Providers contribute a small common presentation candidate. Provider-only DOM references and activation behavior stay behind a closure rather than a public discriminated union.
  4. Generated forms declare their exact action scope and target through internal data-pathmx-* attributes.
  5. Activation calls the connected canonical submitter with requestSubmit. It never constructs FormData, invokes a Context Action, or calls an Action endpoint directly.
  6. Block Action Affordances appear after Block Context Actions. Source Action Affordances appear after Source Context Actions. Beat forms are outside the first slice.
  7. 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:

AttributeBlock formSource form
data-pathmx-formlocal Action Mapping keylocal Action Mapping key
data-pathmx-actionqualified Action IDqualified Action ID
data-pathmx-action-scopeblocksource
data-pathmx-action-targetstable Block IDstable 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;
  • pending is true when the form has aria-busy="true" or data-pathmx-action-state is submitting, awaiting-paint, or delayed;
  • disabled is 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:

  1. form and submitter are still connected;
  2. submitter still belongs to that form;
  3. generated scope, target, Action ID, and local Action are unchanged;
  4. the form still belongs to the active rendered Source; and
  5. 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:

  1. Beat Context Actions
  2. Beat state choices
  3. Beat links
  4. Block Context Actions
  5. Block Action Affordances
  6. Source Context Actions
  7. 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:

  1. Decrease
  2. Increase
  3. Save

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

ConditionPlayer outcome
malformed generated scope/targetomit candidate; report a development diagnostic
submitter detached or changed before activationreturn false; next projection removes or replaces it
native validation failsactivation is handled; browser/form validation owns feedback; no request
form is pendingretain disabled slot; activation returns false
server rejectsexisting inline Action error; same slot re-enables
causal paint replaces formcollect replacement form; stale closure cannot activate
Actor authority or Action Availability changesserver 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 PlayerActionStore lifecycle; and
  • the durable component response end-to-end scenario.

Maintainer Decision

Accept or revise this exact additive contract:

  1. generated data-pathmx-action-scope and data-pathmx-action-target;
  2. the common candidate/snapshot shapes including pending;
  3. provider-owned activation closures rather than a public augmentation union;
  4. separate Block and Source Action Affordance providers in the seven-part order above;
  5. form.requestSubmit(submitter) as the only Player activation path; and
  6. one internal form-state event to refresh pending/rejected projection, with scoped Action Mapping transport accepted as the normal-form prerequisite.