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
- 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.
- 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.
- 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.
- The learned rule is visible position maps to the same key, not label-derived shortcuts or a provider-specific keymap.
- A provider declares local action order, label, disabled state, and behavior. It never declares a shortcut or renders separate Player chrome.
Tabfrom the document focuses the first augmentation pill. Normal button focus owns further Tab order, and nativeEnteractivates the focused pill. PlainEnterwith document focus activates the first eligible Beat link; an active response control keeps priority.- 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.
- 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.
- 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.
- This core slice adds
ctx.play.actionsand 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

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-fullpill with the existing translucentbg-background/50andbackdrop-blur-xlsurface; - 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
Three links
The active Beat contains Docs, Demos, and Domain Language in that order.
The Player shows 1 Docs, 2 Demos, 3 Domain Language.
- Pressing
2activatesDemosimmediately and leaves focus on the document. - Pressing
Enterfrom the document activatesDocs, the first Beat link. - Pressing
Tabfrom the document focusesDocs; a secondTabfocusesDemos;Enteractivates it through the same command as2. Mod+Enterstill 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:
Previous moveNext moveBoardNotationOpen analysisReset exerciseStudy 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.
Fragment, external, target, and download links
- A same-document fragment keeps normal history/hash behavior.
- An external
_selflink follows browser behavior and may leave the Player. _blankand other authored targets remain targets.downloadremains 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
- 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.
- 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.
- Every provider preserves its own authored or DOM order.
- Slots are assigned once after composition:
slot = finalIndex + 1. - Link extraction includes anchors with a non-empty
hrefthat belong to the active Beat's own DOM subtree. - An anchor whose closest Beat is a nested child Beat belongs to that child, not the active parent.
- 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.
- Links under
hidden,aria-hidden="true", orinertancestors are excluded without a geometry read. - Link labels resolve from
aria-label, trimmed visible text,title, image alt,href, thenOpen link. Context Action labels are required at registration. The UI truncates; accessible names retain the full label. - 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 movemerely because they are unavailable. - The first nine presented controls receive direct digit shortcuts. Later augmentations remain pointer/Tab accessible and render without a digit.
- 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.
Navigation history interface
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–9have 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 isO(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
@pathmx/runtimecreatesPathMXContextActionRegistryand exposes it aswindow.pmx.runtime.playActions.- the generated Literate Component wrapper adds owner-bound
ctx.play.actions.set/clearand automatic cleanup. - the Play Session resolves the active Source → Block → Beat projection and subscribes to registry changes.
PlaySessionCommands.activateAugmentationowns live activation. The name is internal and does not settle a public Play Augmentation union.PlayerPlayStoreexposes the current snapshot plus ID, slot, and focus commands.installDefaultPlayerInputActionsreplacesplay.chooseStatewith the unified slot command and adds the document-to-island Tab handoff.PlayControlsreplacesPlayChoiceswith the provider-agnostic minimal pills and conditional history icons.PlayerRouterStoreowns tagged back/forward state.- 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/clearownership, 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/2remain 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:
- 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;
- Literate Components publish a complete ordered list through the exact
ctx.play.actions.set/clearcontract backed by one Runtime registry; - built-in state choices and links feed the same projection; links remain the automatic default and activate real anchors;
- visible position is the digit, disabled actions keep their slot, and Tab hands focus into native Player buttons while Enter remains native;
- the Router tracks only browser-history entries it owns and exposes safe conditional back/forward commands;
- 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.