Player Contextual Actions Contract

This document defines how the Player presents actions associated with the current focus chain: Source, Block, then Beat. SourceLinks are the built-in default. Literate Components and other runtime providers can publish bespoke actions such as previous/next chess moves or video chapters through the same surface.

The product direction is intentionally small: the Player resolves the focus chain into one ordered list of contextual controls and renders each item as a quiet floating pill. The first visible item is always 1, the second is always 2, and so on. Labels never produce changing mnemonic shortcuts.

The maintainer accepted these interfaces and behavior on 2026-07-14. This is the implementation baseline for the first slice.

July 15 coordination note: the Context Action registry, UI, keyboard/focus behavior, links, state choices, and history remain accepted and are in progress. The final generic provider projection, provider order, and mapped save/submit integration are under separate review in Player Action Affordances. Mapped Actions must activate their canonical form submitter and must not be registered through ctx.play.actions.

Decision summary

  1. Play Augmentation is a provisional name for the Player projection. A Context Action, component state choice, or SourceLink can appear in that projection without becoming a PathMX domain Action. This slice does not finalize a discriminated union for those controls.
  2. The active focus chain is the current Source, its active Block, and its active Beat. Providers attach ordered Context Actions to one of those real DOM owners; they do not pass arbitrary Source/Block/Beat IDs.
  3. The first-slice implementation composes Beat Context Actions, Beat state choices, Beat links, Block Context Actions, then Source Context Actions. That composition is injectable and remains provisional while the Action Affordance addendum reviews the complete provider order. Stable order inside each provider and stable visible-position shortcuts are required now.
  4. The learned rule is visible position maps to the same key, not label-derived shortcuts or a provider-specific keymap.
  5. A provider declares local action order, label, disabled state, and behavior. It never declares a shortcut or renders separate Player chrome.
  6. Tab from the document focuses the first augmentation pill. Normal button focus owns further Tab order, and native Enter activates the focused pill. Plain Enter with document focus activates the first eligible Beat link; an active response control keeps priority.
  7. Pointer and keyboard activation call the same Player command. Links activate their real anchors; Context Actions invoke their registered behavior. Disabled actions stay visible and keep their slots.
  8. Internal Source navigation keeps Play Mode and URL search parameters. The Router tags and tracks the browser-history entries it owns, so its back and forward controls cannot accidentally leave the Player trail.
  9. The Play store remembers the last active Beat per route for the current Player mount. Returning to a Source restores that Beat if it still exists.
  10. This core slice adds ctx.play.actions and one browser-Runtime registry. It adds no Markdown syntax, Graph Index fields, durable state, Action Mapping, or Actor authority behavior. Form-backed Action Affordances remain a distinct additive contract.

Visual contract

Minimal floating Play Augmentations

The image records composition, not pixel-perfect final values. The code must reuse the Player's existing ACTION_BUTTON_SURFACE_BG language and theme tokens.

Required treatment:

  • render each augmentation as its own rounded-full pill with the existing translucent bg-background/50 and backdrop-blur-xl surface;
  • do not render a provider label, shared toolbar background, enclosing panel, route icon, persistent help text, or visible separator;
  • show only the low-emphasis slot and the action label;
  • use a quiet fill change plus the normal accessible focus-visible treatment; do not add a large glow or permanent cyan outline;
  • render history as conditional icon-only circular controls;
  • constrain the action row between the DPad and right-side controls, keep one line, and use horizontal overflow without a visible scrollbar at narrow widths;
  • use 32px targets on fine pointers and the existing 40–48px coarse-pointer treatment.

The augmentation row is absent when the current focus chain resolves no items. Layout space is not reserved for it.

Scenarios

The active Beat contains Docs, Demos, and Domain Language in that order. The Player shows 1 Docs, 2 Demos, 3 Domain Language.

  • Pressing 2 activates Demos immediately and leaves focus on the document.
  • Pressing Enter from the document activates Docs, the first Beat link.
  • Pressing Tab from the document focuses Docs; a second Tab focuses Demos; Enter activates it through the same command as 2.
  • Mod+Enter still opens/submits the Composer.

Chess component

The focused component Beat publishes Previous move and Next move in that order. The Player shows 1 Previous move, 2 Next move. At the start of the line, Previous move remains in slot 1 but is disabled; Next move remains 2. Moving through the line updates disabled state without moving either key.

function publishActions() {
  ctx.play.actions.set([
    {
      id: "previous-move",
      label: "Previous move",
      disabled: moveIndex === 0,
      run: previousMove,
    },
    {
      id: "next-move",
      label: "Next move",
      disabled: moveIndex === moves.length - 1,
      run: nextMove,
    },
  ])
}

The component calls publishActions() after its move index changes. It does not know whether the Player is visible, bind digit keys, or render Player chrome.

Video chapters

A video component publishes chapter actions in chapter order. The first nine receive direct digits; later chapters remain pointer/Tab accessible in the horizontal row. A component with many chapters may instead publish three stable actions—Previous chapter, Chapters, Next chapter—whose run callbacks operate its existing video state.

Focus-chain composition

The focused Beat publishes Previous move and Next move, declares state choices Board and Notation, and contains Open analysis. Its Block also publishes Reset exercise; its Source publishes Study guide. The current first-slice provider composition shows:

  1. Previous move
  2. Next move
  3. Board
  4. Notation
  5. Open analysis
  6. Reset exercise
  7. Study guide

This example records the working core-only composition, not the final provider precedence contract. Labels never affect order, and the injected provider seam allows the complete composition to be reviewed without changing the pills or their keyboard behavior.

Internal Source navigation

Activating an internal routed link uses the real anchor click path. The Runtime emits its existing route-activate event, RuntimeSurface calls the Player Router, the current search parameters (including play=1) remain in the URL, and the next Source mounts without leaving Play Mode.

The Player remembers the departed Source's active Beat. Going back restores that Beat if it still exists; otherwise the normal initial Beat is used.

  • A same-document fragment keeps normal history/hash behavior.
  • An external _self link follows browser behavior and may leave the Player.
  • _blank and other authored targets remain targets.
  • download remains a download.

The Player does not reinterpret these as Source Routes.

Stale or rebuilt DOM

If a live rebuild replaces an anchor or registered owner between render and activation, the command returns false and does not invoke stale behavior. The next focus projection replaces the pill. Errors after a valid link activation stay owned by the existing Runtime or browser path.

Context Action provider

The browser Runtime owns one in-process registry. The generated Literate Component context exposes only the owner-bound convenience API.

The accepted Literate Component MVP intentionally deferred ctx.play until a real component needed richer Player commands. Chess move controls and video chapters are that concrete need; this contract adds only the narrow action surface required by them.

export type PathMXContextAction = Readonly<{
  /** Unique and stable within this owner. */
  id: string
  label: string
  disabled?: boolean
  run(): void | Promise<void>
}>

export type PathMXContextActionSnapshot = Omit<PathMXContextAction, "run">

export type PathMXContextActionRegistry = {
  replace(owner: Element, actions: readonly PathMXContextAction[]): void
  clear(owner: Element): void
  list(owner: Element): readonly PathMXContextActionSnapshot[]
  invoke(owner: Element, actionId: string): boolean
  subscribe(listener: (owner: Element) => void): () => void
}

export type PathMXBrowserRuntime = {
  // existing members
  playActions: PathMXContextActionRegistry
}

export type LiteratePlayActions = {
  set(actions: readonly PathMXContextAction[]): void
  clear(): void
}

export type LiteratePlayContext = {
  actions: LiteratePlayActions
}

export type LiterateContext = {
  // existing members
  play: LiteratePlayContext
}

ctx.play.actions.set(...) delegates to window.pmx.runtime.playActions.replace(el, actions). The component root is the owner; authors never pass a scope ID. set replaces that owner's complete ordered list, validates unique non-empty IDs and labels, and notifies the Player. Component disposal clears the registration automatically.

invoke returns false when the owner is detached, the action is missing, or it is disabled. Otherwise it calls run and returns true immediately. The registry catches synchronous errors and rejected promises and reports them through the existing component error channel; it does not invent generic success state or a Player toast.

This first slice is an in-document browser Runtime seam, matching the current web Player. The descriptors are serializable, and invoke(owner, id) keeps the callback hidden, so a future iframe/native bridge can adapt the same interface without changing component authoring. Cross-realm transport is not part of the Build Week slice.

Presented action seam

This slice deliberately does not settle a discriminated Play Augmentation union. The Play Session retains provider-specific live activation closures; React receives only a DOM-free snapshot with a transient ID, one-based slot, label, disabled state, and optional selected state.

The provider seam is internal and injected. It accepts the active route, Block, Beat, optional Context Action registry, and the existing state-choice request command. A provider contributes an ordered list of candidates with an activation closure. The resolver prefixes transient identities and assigns slots after composition.

These details are sufficient for the UI and input contract while leaving both the final candidate shape and provider precedence open. A later mapped PathMX Action Affordance provider must remain distinct from ctx.play.actions and activate the canonical form submitter. It must not be added by widening a Context Action union member or routing durable behavior through the browser Runtime registry.

Presented controls are a projection of current focus, not intrinsic data copied onto every PlayBeat. The Session recomposes the active set when focus changes, the Play Route rebuilds, or the Context Action registry changes. Existing Beat choices remain the choreography source but no longer own digit dispatch. Transient Player identities are not Source-authored IDs, Action Mapping names, or durable PathMX Action IDs.

Extraction and ordering invariants

  1. The Player derives the active owner chain from the live route: Source element, active Block element, active Beat element. A registry owner must be one of those scoped elements; unknown owners are ignored.
  2. Provider composition is injected. The current core-only list is Beat Context Actions, Beat state choices, Beat links, Block Context Actions, then Source Context Actions; this is not the final provider precedence contract.
  3. Every provider preserves its own authored or DOM order.
  4. Slots are assigned once after composition: slot = finalIndex + 1.
  5. Link extraction includes anchors with a non-empty href that belong to the active Beat's own DOM subtree.
  6. An anchor whose closest Beat is a nested child Beat belongs to that child, not the active parent.
  7. PathMX-generated heading self-links are excluded when the anchor fragment targets the containing heading itself. Otherwise every heading would acquire a misleading navigation action.
  8. Links under hidden, aria-hidden="true", or inert ancestors are excluded without a geometry read.
  9. Link labels resolve from aria-label, trimmed visible text, title, image alt, href, then Open link. Context Action labels are required at registration. The UI truncates; accessible names retain the full label.
  10. Disabled Context Actions remain visible and retain their slot. Providers replace the list to update disabled state; they do not remove boundary actions such as Previous move merely because they are unavailable.
  11. The first nine presented controls receive direct digit shortcuts. Later augmentations remain pointer/Tab accessible and render without a digit.
  12. Context Action IDs must be unique within one owner. IDs may repeat at different Source/Block/Beat scopes because the projected identity includes the scope.

The first slice does not call getBoundingClientRect() or computed style to decide link eligibility. Avoiding layout reads keeps focus composition linear and deterministic; CSS-only hidden descendants are a known edge for later work if real content exposes it.

Command and input interfaces

type PlaySessionCommands = {
  // existing navigation commands
  activateAugmentation(augmentationId: string): boolean
}

class PlayerPlayStore {
  activateAugmentation(augmentationId: string): boolean
  activateAugmentationAtSlot(slot: number): boolean
  focusFirstAugmentation(): boolean
}

activateAugmentation(augmentationId) resolves against the current live focus projection. It returns false when focus changed, the item is missing or disabled, its anchor is detached, or its Context Action owner is no longer in the active scope chain. It returns true after requesting a state, clicking the real anchor, or asking the Runtime registry to invoke the Context Action.

activateAugmentationAtSlot(slot) resolves the current snapshot item and then activates by ID. Pointer controls also activate by ID. Controls never reach into live DOM or provider callbacks.

The default input map becomes:

keymap.bindAll(
  ["1", "2", "3", "4", "5", "6", "7", "8", "9"],
  "play.activateAugmentationAtSlot",
)
keymap.bind("Tab", "play.focusFirstAugmentation")

The digit handler calls Number(event.key). The Tab handler is enabled only when Play Mode has active augmentations and the event target is not already interactive/editable. Once an action button has focus, native Tab order and native Enter activation take over; the Player does not create a non-modal focus trap.

Existing exclusive Composer/modal/grid surfaces keep priority. Digit and Tab bindings do not fire from inputs, textareas, selects, buttons, links, contenteditable regions, or textboxes.

The Router continues to own browser URL/history writes. Play Augmentations do not create a second route stack.

export type PlayerRouterHistory = Pick<
  History,
  "pushState" | "replaceState" | "back" | "forward"
>

type PlayerRouteHistoryState = {
  pathmxPlayer: true
  route: string
  entryId: number
}

class PlayerRouterStore {
  back(): boolean
  forward(): boolean

  $ = observable({
    // existing fields
    canGoBack: false,
    canGoForward: false,
  })
}

The Router keeps a private in-memory ordered list of its entryId values and the current index. A push truncates its forward tail, allocates an entry, and writes that ID into browser history state. A replace keeps the current ID. A known popstate updates the index. An unknown/external history state resets the owned trail to the current entry rather than claiming it.

back() and forward() return false and do nothing unless the corresponding owned entry exists. Therefore an icon-only Player control never sends the user past the current Player-owned trail. Browser-native back/forward remain available for broader history.

The Router keeps current search parameters when committing a Source Route, as it does today. That is what preserves play=1; the augmentation resolver does not copy or reconstruct query parameters.

Beat restoration

PlayerPlayStore keeps a bounded in-memory map from route key to last active Beat ID. Before resetForRoute clears a route, it records the current Beat. When a route returns, the new session adopts the remembered Beat if the rebuilt route still contains it, otherwise it uses the initial Beat.

  • This is Play Mode UI state, not a durable Play Session.
  • It is not written into the URL, Source, or Graph Index.
  • The map is capped at 50 routes with oldest-entry eviction.
  • Leaving Play Mode clears it.

Module and seam placement

Deep module: the Play Session owns one contextual-control resolver for the current focus chain.

Hidden responsibilities:

  • derive the active Source → Block → Beat owner chain;
  • collect ordered Context Actions from the Runtime registry;
  • derive Beat state choices and links through built-in providers;
  • assign slots and transient scoped IDs;
  • retain live anchors and registry owners outside React state;
  • recompose only when focus, route shape, or an active owner registration changes;
  • reject stale activation and preserve real anchor semantics.

Dependencies:

  • DOM extraction is local-substitutable through the existing Play DOM test harness;
  • the browser Runtime Play Action registry is an in-process owned dependency for the current web Player and an explicit future adapter seam for cross-realm Players;
  • Player Router to browser history is local-substitutable through PlayerRouterWindow;
  • Runtime navigation is remote-but-owned and remains behind the existing route-activate event seam;
  • no new external adapter is introduced.

The interface is the test surface. Tests should drive route construction, session/store commands, input actions, controls, and Router behavior through the same interfaces used by the Player.

Error and pending behavior

  • Missing, disabled, or stale Play Augmentation: return false; do not click or toast.
  • Valid internal link: existing Runtime pending-route and error UI owns loading and failure after activation.
  • External/target/download link: browser behavior owns the outcome.
  • Valid Context Action: the Runtime registry calls its run; synchronous errors and rejected promises use the component error channel. The Player does not assume success or invent pending UI.
  • History edge: return false; the unavailable control is absent.
  • Remembered Beat removed by rebuild: fall back to the initial Beat.
  • More than nine augmentations: only slots 1–9 have digits; later items remain Tab/pointer accessible.

A mapped PathMX Action Affordance must not register a Context Action. The proposed built-in provider instead projects the canonical form submitter and calls form.requestSubmit(submitter), leaving Action Availability, authority, input, Run state, pending, retry, and canonical paint in their existing owners. See Player Action Affordances.

Performance behavior

  • Focus composition is O(active registered actions + active Beat anchors + active Beat state choices) and performs no network access or layout reads.
  • Registry replacement is O(owner actions). A change on an inactive owner does not recompose Player chrome.
  • Augmentation rendering is O(active augmentations) and does not scan the complete document.
  • Direct slot lookup is O(1) through the current augmentation array; ID activation is O(active augmentations), bounded by the focus chain.
  • History navigation is O(1); remembered Beat state is capped at 50 routes.
  • No augmentation data enters the Graph Index or build artifact schema. The first slice adds only the in-process browser Runtime registry; bridge payloads remain unchanged.

Real callers and migration

  1. @pathmx/runtime creates PathMXContextActionRegistry and exposes it as window.pmx.runtime.playActions.
  2. the generated Literate Component wrapper adds owner-bound ctx.play.actions.set/clear and automatic cleanup.
  3. the Play Session resolves the active Source → Block → Beat projection and subscribes to registry changes.
  4. PlaySessionCommands.activateAugmentation owns live activation. The name is internal and does not settle a public Play Augmentation union.
  5. PlayerPlayStore exposes the current snapshot plus ID, slot, and focus commands.
  6. installDefaultPlayerInputActions replaces play.chooseState with the unified slot command and adds the document-to-island Tab handoff.
  7. PlayControls replaces PlayChoices with the provider-agnostic minimal pills and conditional history icons.
  8. PlayerRouterStore owns tagged back/forward state.
  9. Runtime link activation remains unchanged.

After migration, remove the direct play.chooseState input/store command. PlayBeat.choices remains the choreography source consumed by the resolver.

Verification contract

Focused tests must cover:

  • Runtime registry replace/list/invoke/subscribe behavior, owner cleanup, duplicate IDs, disabled actions, thrown errors, and rejected promises;
  • generated ctx.play.actions.set/clear ownership, replacement, and component disposal;
  • Beat → Block → Source Context Action composition, built-in state/link ordering, one-based slots, and DOM-free snapshots;
  • link label/order extraction, nested Beat exclusion, and generated heading self-link exclusion;
  • pointer, 1–9, Tab/native Enter, editable targets, Composer/modal priority, disabled/missing actions, and registry updates while focused;
  • internal Source Routes, fragments, external targets, and downloads through real anchors;
  • a chess component proof where 1/2 remain Previous/Next while disabled state changes, plus ordered video-chapter actions;
  • query preservation, owned back/forward boundaries, unknown popstate reset, and remembered Beat restoration/fallback;
  • desktop, narrow, and coarse-pointer layout without collision.

Build/release verification after implementation:

bun --filter @pathmx/player test
bun --filter @pathmx/player typecheck
bun run dev:player-runtime -- check
bun run packages/cli/src/bin.ts build -o .pathmx-check
bun run release:pathmx

The release dry run is required because the Build Week CLI ships generated Player assets; a source-only Player pass is not enough.

Maintainer acceptance

Implementation proceeds from these six contract points:

  1. the Player resolves contextual actions from the active Source → Block → Beat owner chain through an injected provider seam; the core-only composition is provisional until the complete provider order is accepted;
  2. Literate Components publish a complete ordered list through the exact ctx.play.actions.set/clear contract backed by one Runtime registry;
  3. built-in state choices and links feed the same projection; links remain the automatic default and activate real anchors;
  4. visible position is the digit, disabled actions keep their slot, and Tab hands focus into native Player buttons while Enter remains native;
  5. the Router tracks only browser-history entries it owns and exposes safe conditional back/forward commands;
  6. revisited Sources restore their last valid Beat for the current Player mount, with no durable or URL state.

Implementation must return for review if it needs Markdown/source schema, cross-realm bridge changes, Actor-domain Action/authority changes, provider-specific keymaps, or a second navigation stack.