feat: Keyboard-first order entry UX: auto-focus, auto-select, Tab navigation & inline validation#41949
Conversation
…igation & inline validation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
Builds ready [6bb62bc]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 8 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
- Auto-focus primary input on every perps order screen (size on Trade, limit price on Limit switch, margin on Edit margin, TP trigger on TP/SL, Close button on Position close). - Auto-select existing value on focus so typing replaces it. - Contextual `min $10` placeholder and `Minimum order size $10` disabled submit copy until the market-order size clears PERPS_MIN_MARKET_ORDER_USD. - Render the order-entry page as a native <form onSubmit> with the primary button set to type="submit" so the browser's Enter-to-submit behavior fires when the button is enabled and is naturally blocked when disabled. Edit-margin and TP/SL modals attach input-level Enter handlers guarded against Shift+Enter and IME composition. - Unit coverage for focus, select-on-focus, placeholder, min-order gate, and form-submission semantics across all five screens.
…d-order-entry-ux # Conflicts: # ui/pages/perps/perps-order-entry-page.tsx
✨ Files requiring CODEOWNER review ✨👨🔧 @MetaMask/perps (18 files, +877 -88)
|
Builds ready [0850807]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 8 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
Builds ready [7f629d3]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 8 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
Worker reportTAT-2802 — Keyboard-first order entry UXTicket: TAT-2802 SummaryOrder entry now auto-focuses the primary input on every perps order screen, auto-selects the existing value on focus, gates submit with inline validation, and shows a contextual ChangesProduction code:
Tests:
Test plan
Evidence
Notes
|
Builds ready [e3f8e6e]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 8 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
Builds ready [d862730]
⚡ Performance Benchmarks (Total: 🟢 0 pass · 🟡 0 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
The shared in-progress toast block was emitting SUBMIT_IN_PROGRESS for new orders unconditionally. shouldShowOrderSubmissionToasts only gated the success toast, so the "Submitting your trade" toast leaked while a deposit was still pending — a regression vs main behavior. Hoist the guard above the in-progress block and skip the new-order toast emit when shouldShowOrderSubmissionToasts is false.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 426f037. Configure here.
Builds ready [426f037]
⚡ Performance Benchmarks (Total: 🟢 0 pass · 🟡 0 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
Builds ready [ce2cb1d]
⚡ Performance Benchmarks (Total: 🟢 0 pass · 🟡 0 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
michalconsensys
left a comment
There was a problem hiding this comment.
why are there changes to app/images/icon-512.png?
Wire the contextual perps size placeholder through OrderEntry for market orders, apply token-input select-all after the editing rerender, cover the leverage new-code paths required by coverage analysis, and remove unrelated optimized PNG deltas from the PR diff. Constraint: Review fixes must stay inside the existing PR surface and remove unrelated image diffs from the branch.\nRejected: Keep optimized PNG changes | unrelated to keyboard-first perps order entry and called out by the user.\nConfidence: high\nScope-risk: narrow\nTested: yarn lint:changed && yarn verify-locales --quiet && yarn circular-deps:check\nTested: affected jest command from task plus targeted OrderEntry and LeverageSlider suites\nTested: node temp/runtime/coverage-analyze.js\nNot-tested: Inherited recipe product path; blocked by missing shared flow perps/open-order-form before exercising behavior.
Builds ready [560621c]
⚡ Performance Benchmarks (Total: 🟢 0 pass · 🟡 0 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
Align the perps order-entry tests and formatted assets with the CI lint gate so the PR can pass the strict validation suite.\n\nConstraint: CI reported Test lint failure on PR #41949\nConfidence: high\nScope-risk: narrow\nTested: yarn lint && yarn verify-locales --quiet && yarn circular-deps:check\nTested: yarn jest ui/pages/perps/perps-order-entry-page.test.tsx --no-coverage
Remove image optimizer output that was accidentally included in the CI-fix commit. The perps test and formatting fixes remain intact.\n\nConstraint: PR fix must include only relevant CI changes\nConfidence: high\nScope-risk: narrow\nTested: git diff HEAD~1..HEAD --name-only
Merged origin/main into the PR branch and resolved the TP/SL TextField conflict by preserving the branch's keyboard/focus behavior while adopting main's testId prop usage.\n\nConstraint: Keep PR conflict-free without re-running lint because the image linter rewrites unrelated PNG assets\nRejected: Re-run full lint gate | it recreates unrelated image optimizer changes on this branch\nConfidence: high\nScope-risk: narrow\nTested: yarn jest ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx --no-coverage\nNot-tested: Full lint gate intentionally not run
Builds ready [22a599f]
⚡ Performance Benchmarks (Total: 🟢 0 pass · 🟡 0 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
|
Builds ready [3ffa336]
⚡ Performance Benchmarks (Total: 🟢 0 pass · 🟡 0 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|




Description
Delivers the keyboard-first order entry UX across every perps order surface:
Order size must be at least $10button copy until the amount clears the minimum.<form>with anonSubmithandler and atype="submit"button, so the browser's built-in Enter-to-submit behavior drives it (disabled buttons naturally block submission). Add margin and TP/SL modals wire an Enter keydown directly on their numeric inputs; both paths guard against Shift+Enter and IME composition.minLeverage..maxLeverage), matching how native numeric inputs behave.Submit flow is wired so the in-progress toast transitions cleanly to the success state: the perps RPC is awaited before navigating off the order-entry page, and the in-progress toast is emitted once from the page itself rather than re-emitted via route state on the market-detail page. The shared background RPC client (
metaRPCClientFactory) also tolerates error responses with a missing or emptymessagefield so a pending request is always settled instead of orphaned.Changelog
CHANGELOG entry: Improved perps order entry with auto-focus, auto-select-on-focus, real-time minimum-order-size validation, and Enter-to-submit keyboard shortcut.
Related issues
Fixes: TAT-2802
Manual testing steps
$size input is already focused and the submit button readsOrder size must be at least $10and is disabled.5— submit stays disabled and still readsOrder size must be at least $10.15— submit enables and readsOpen long ETH.maxLeverage); press ArrowDown to step by −1 (clamped atminLeverage).Screenshots/Recordings
caption confidence: LOW — generic filename — no state-specific suffix
Pre-merge author checklist
Pre-merge reviewer checklist
Validation Recipe
Task-local bundle convention: one thin orchestrator recipe + nine AC-focused self-runnable subflows, invoked via explicit
bundle/<name>refs. Live runs on CDP 6662:Main recipe end-to-end: 9/9 PASS (all subflows green, guarded teardown closes the ETH position opened by AC7 positive).
AC7 positive standalone (full Enter path): 17/17 PASS —
wait-position-in-state(state-based wait viaperpsGetPositions) typically resolves in ~3s.AC7 positive standalone (idempotent skip branch, pre-existing position): 6/6 PASS —
branch-positionshort-circuits toverify-position-dom.Main recipe:
temp/.task/feat/tat-2802-0420-210839/artifacts/recipe.json— pure orchestrator, 10 nodes (9callnodes tobundle/<name>subflows +done+ one-step teardown callingperps/close-position).Subflow bundle:
temp/.task/feat/tat-2802-0420-210839/artifacts/recipe-flows/— 9 flat subflow files, each self-runnable and generically parametrized (inputs: symbol/side/sideLabel/amount with sensible defaults). Each delegates the entire form-open prelude to a single{ call: perps/open-order-form, params: { symbol, side } }step — no duplicated setup nodes. Reviewers can run any subflow in isolation vianode temp/agentic/recipes/validate-recipe.js --recipe temp/.task/feat/tat-2802-0420-210839/artifacts/recipe-flows/<ac>.json --cdp-port 6662 --skip-manualor validate any (symbol, side) combination by passing--input symbol=BTC --input side=short --input sideLabel=Short.New shared canonical flow:
temp/agentic/recipes/domains/perps/flows/open-order-form.json— extracted so every subflow (and future callers) can open a fresh{side}order form on{symbol}in one line. Composition:perps/ensure-perps-network→perps/prime-perps-state→perps/navigate-to-market-detail→perps/close-position→wait-{side}-cta→press-{side}→wait-order-form.AC-to-subflow map
bundle/ac1-size-autofocusbundle/ac1-limit-autofocusbundle/ac2-select-on-focusselectionStart==0 && selectionEnd==lengthbundle/ac5-below-min5, assert submit stays disabled with min-order copybundle/ac5-valid-amount{{amount}}, assertOpen {{sideLabel}} {{symbol}}+ enabled + screenshot (parametric — defaults validateOpen long ETH).bundle/ac6-empty-buttonOrder size must be at least $10disabled copy + screenshotbundle/ac7-enter-blocked-assertformMounted + hashStillNew + buttonStillDisabled. Canonical standalone covers the empty-form case.bundle/ac7-enter-submit-successpre_conditions+ balance gate viaperpsGetAccountState+branch-position(if{{symbol}}position already exists → verify and pass; else full submit path). Switch to market, type{{amount}}, focus, press Enter, wait market-detail + state-based wait onperpsGetPositions+ position-live + direction regex/{{sideLabel}}\s+\d+(?:\.\d+)?x/, assert route locked to#/perps/market/{{symbol}}, form unmounted, screenshot. Mirrors canonicalperps/open-long-position.bundle/leverage-keyboardselectionStart==0 && selectionEnd==length; press ArrowUp; assert value increments (or clamps atmaxLeverage); refocus; press ArrowDown; assert value decrements (or clamps atminLeverage). State-agnostic about the initial value.Each subflow delegates its entire form-open prelude to the shared canonical flow
perps/open-order-form— which itself composesperps/ensure-perps-network+perps/prime-perps-state+perps/navigate-to-market-detail+perps/close-position+ parameterized CTA press. No subflow repeats plumbing. Main recipe teardown simply callsperps/close-position, whose own internal branch makes it a no-op when no position is visible. AC7 positive proof is UI-success-state driven AND backend-state driven (no network probe): redirect + state-wait onperpsGetPositions+ live position + direction regex + route lock.Reproduce locally — copy-paste recipe bundle (click to expand)
The recipe + 9 subflows below are inlined verbatim. They live under
temp/in the working slot (gitignored), so they don't ship via the PR diff — copy them into your local checkout to run.Quickstart
Each subflow accepts
--param symbol=BTC --param side=short(andsideLabel=Short) for free re-parameterization — the validator only ships ETH-long defaults.Files
artifacts/recipe.json{ "title": "TAT-2802 — Keyboard-first order entry UX (composable bundle)", "description": "Orchestrator recipe. Each AC subflow under recipe-flows/ is fully self-contained (pre_conditions + setup that primes perps, navigates, closes any lingering ETH position, opens a fresh order form). The main recipe just calls each subflow in sequence via bundle/<name> refs and runs a safety teardown to close any ETH position left behind.", "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "teardown": [ { "id": "teardown-close-eth", "action": "call", "ref": "perps/close-position", "params": { "symbol": "ETH", "percent": "100" } } ], "entry": "ac1-size-autofocus", "nodes": { "ac1-size-autofocus": { "action": "call", "ref": "bundle/ac1-size-autofocus", "description": "AC1 — size input auto-focus + focused testid proof (self-contained)", "next": "ac6-empty-button" }, "ac6-empty-button": { "action": "call", "ref": "bundle/ac6-empty-button", "description": "AC6 — 'Order size must be at least $10' disabled button copy while size empty + screenshot (self-contained)", "next": "ac7-neg-empty" }, "ac7-neg-empty": { "action": "call", "ref": "bundle/ac7-enter-blocked-assert", "description": "AC7 negative — Enter blocked while button disabled (empty case, self-contained)", "next": "ac5-below-min" }, "ac5-below-min": { "action": "call", "ref": "bundle/ac5-below-min", "description": "AC5 — typing $5 keeps button disabled with min-order copy (self-contained)", "next": "ac5-valid-amount" }, "ac5-valid-amount": { "action": "call", "ref": "bundle/ac5-valid-amount", "description": "AC5/AC6 — typing $15 enables button with 'Open long ETH' copy + screenshot (self-contained)", "next": "ac2-select-on-focus" }, "ac2-select-on-focus": { "action": "call", "ref": "bundle/ac2-select-on-focus", "description": "AC2 — blur + refocus selects existing value in full (self-contained, types $15 first)", "next": "leverage-keyboard" }, "leverage-keyboard": { "action": "call", "ref": "bundle/leverage-keyboard", "description": "Leverage input — select-on-focus + ArrowUp/ArrowDown step (self-contained)", "next": "ac1-limit-autofocus" }, "ac1-limit-autofocus": { "action": "call", "ref": "bundle/ac1-limit-autofocus", "description": "AC1 (limit variant) — switching to limit order type auto-focuses limit-price-input + screenshot (self-contained)", "next": "ac7-enter-submit-success" }, "ac7-enter-submit-success": { "action": "call", "ref": "bundle/ac7-enter-submit-success", "description": "AC7 positive — resilient idempotent. Pre-conditions + balance gate + branch-position: if ETH position already exists from a prior run, treat as prior AC7 proof and skip resubmission; otherwise press Long CTA → open order form → switch to market → type $15 → focus size input → press Enter → wait market-detail → state-based wait on perpsGetPositions until ETH position appears → verify position DOM + direction regex + route lock + screenshot", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/ac1-size-autofocus.json{ "title": "AC1 — Size input auto-focus on order form mount ({{side}} {{symbol}})", "description": "Self-runnable. Opens a fresh {{side}} order form on {{symbol}} via perps/open-order-form, then asserts document.activeElement is inside amount-input-field (market mode default).", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "wait-autofocus", "nodes": { "wait-autofocus": { "action": "wait_for", "expression": "!!document.activeElement && document.activeElement.closest('[data-testid=\"amount-input-field\"]') !== null", "assert": { "operator": "eq", "value": true }, "timeout_ms": 3000, "next": "autofocus-proof" }, "autofocus-proof": { "action": "eval_sync", "expression": "JSON.stringify({ focusedTestId: document.activeElement?.closest('[data-testid]')?.getAttribute('data-testid'), tag: document.activeElement?.tagName })", "assert": { "operator": "eq", "field": "focusedTestId", "value": "amount-input-field" }, "save_as": "ac1_market", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/ac1-limit-autofocus.json{ "title": "AC1 (limit variant) — Switching to limit order type auto-focuses limit price input ({{side}} {{symbol}})", "description": "Self-runnable. Opens a fresh {{side}} order form on {{symbol}}, presses the limit toggle, waits for limit-price-input to mount, asserts focus lands inside it, screenshots evidence.", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "switch-limit", "nodes": { "switch-limit": { "action": "press", "test_id": "order-type-limit", "next": "wait-limit-mount" }, "wait-limit-mount": { "action": "wait_for", "test_id": "limit-price-input", "timeout_ms": 5000, "next": "wait-limit-autofocus" }, "wait-limit-autofocus": { "action": "wait_for", "expression": "!!document.activeElement && document.activeElement.closest('[data-testid=\"limit-price-input\"]') !== null", "assert": { "operator": "eq", "value": true }, "timeout_ms": 3000, "next": "limit-autofocus-proof" }, "limit-autofocus-proof": { "action": "eval_sync", "expression": "JSON.stringify({ focusedTestId: document.activeElement?.closest('[data-testid]')?.getAttribute('data-testid') })", "assert": { "operator": "eq", "field": "focusedTestId", "value": "limit-price-input" }, "save_as": "ac1_limit", "next": "screenshot-limit" }, "screenshot-limit": { "action": "screenshot", "filename": "evidence-limit-autofocus", "note": "AC1 (limit variant) — switching to limit order type auto-focuses the limit price input", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/ac2-select-on-focus.json{ "title": "AC2 — Existing value auto-selected on input refocus ({{side}} {{symbol}} ${{amount}})", "description": "Self-runnable. Opens a fresh {{side}} order form on {{symbol}}, types {{amount}} so the input has a value, then blurs + refocuses the amount-input-field inner input and polls until selectionStart/selectionEnd span the full value length.", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" }, "amount": { "type": "string", "default": "15", "description": "USD notional pre-typed so the input has a value for the select-on-focus proof" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "prelude-type-value", "nodes": { "prelude-type-value": { "action": "set_input", "test_id": "amount-input-field", "value": "{{amount}}", "next": "blur-input" }, "blur-input": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"amount-input-field\"] input');if(!i)return JSON.stringify({blurred:false});i.blur();return JSON.stringify({blurred:document.activeElement!==i});})()", "assert": { "operator": "eq", "field": "blurred", "value": true }, "next": "refocus-input" }, "refocus-input": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"amount-input-field\"] input');if(!i)return JSON.stringify({focused:false});i.focus();return JSON.stringify({focused:document.activeElement===i});})()", "assert": { "operator": "eq", "field": "focused", "value": true }, "next": "select-on-focus" }, "select-on-focus": { "action": "wait_for", "expression": "(function(){var i=document.querySelector('[data-testid=\"amount-input-field\"] input');var len=(i&&i.value||'').length;return JSON.stringify({selected: !!i && len>0 && i.selectionStart===0 && i.selectionEnd===len, len:len, start:i&&i.selectionStart, end:i&&i.selectionEnd});})()", "assert": { "operator": "eq", "field": "selected", "value": true }, "timeout_ms": 2000, "poll_ms": 100, "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/ac5-below-min.json{ "title": "AC5 — Below-min amount keeps submit disabled with min-order copy ({{side}} {{symbol}})", "description": "Self-runnable. Opens a fresh {{side}} order form on {{symbol}}, types {{amount}} into the amount-input-field and asserts submit-order-button still reads 'Order size must be at least $10' and is disabled.", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" }, "amount": { "type": "string", "default": "5", "description": "Below-min USD notional" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "type-below-min", "nodes": { "type-below-min": { "action": "set_input", "test_id": "amount-input-field", "value": "{{amount}}", "next": "assert-still-disabled" }, "assert-still-disabled": { "action": "eval_sync", "expression": "(function(){var b=document.querySelector('[data-testid=\"submit-order-button\"]');return JSON.stringify({ text: (b?.textContent||'').trim(), disabled: !!b?.disabled });})()", "assert": { "all": [ { "field": "text", "operator": "eq", "value": "Order size must be at least $10" }, { "field": "disabled", "operator": "eq", "value": true } ]}, "save_as": "ac5_below_min", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/ac5-valid-amount.json{ "title": "AC5/AC6 — Valid amount enables submit with action-specific copy ({{side}} {{symbol}} ${{amount}})", "description": "Self-runnable. Opens a fresh {{side}} order form on {{symbol}}, types {{amount}} into the amount-input-field and asserts submit-order-button enables and reads the expected side+symbol copy. Captures evidence screenshot. expectedCopy is derived from side+symbol so BTC short still asserts correctly.", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" }, "amount": { "type": "string", "default": "15", "description": "Valid USD notional at or above the minimum" }, "sideLabel": { "type": "string", "default": "Long", "description": "Title-cased side label retained for backwards compatibility; not used in the submit-button assertion (locale is sentence-case post #41881)" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "type-valid", "nodes": { "type-valid": { "action": "set_input", "test_id": "amount-input-field", "value": "{{amount}}", "next": "assert-enabled" }, "assert-enabled": { "action": "eval_sync", "expression": "(function(){var b=document.querySelector('[data-testid=\"submit-order-button\"]');return JSON.stringify({ text: (b?.textContent||'').trim(), disabled: !!b?.disabled });})()", "assert": { "all": [ { "field": "text", "operator": "eq", "value": "Open {{side}} {{symbol}}" }, { "field": "disabled", "operator": "eq", "value": false } ]}, "save_as": "ac5_enabled", "next": "screenshot-valid" }, "screenshot-valid": { "action": "screenshot", "filename": "evidence-valid-amount", "note": "AC5 — submit button reads 'Open {{side}} {{symbol}}' (sentence-case per locale perpsOpenLong/Short) and is enabled after a valid amount (>= $10)", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/ac6-empty-button.json{ "title": "AC6 — 'Order size must be at least $10' disabled button copy when size empty ({{side}} {{symbol}})", "description": "Self-runnable. Opens a fresh {{side}} order form on {{symbol}}, asserts submit-order-button textContent equals 'Order size must be at least $10' and the button is disabled while the amount input is empty. Captures evidence screenshot.", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "assert-empty-copy", "nodes": { "assert-empty-copy": { "action": "eval_sync", "expression": "(function(){var b=document.querySelector('[data-testid=\"submit-order-button\"]');return JSON.stringify({ text: (b?.textContent||'').trim(), disabled: !!b?.disabled });})()", "assert": { "all": [ { "field": "text", "operator": "eq", "value": "Order size must be at least $10" }, { "field": "disabled", "operator": "eq", "value": true } ]}, "save_as": "ac6_empty", "next": "screenshot-min-order-copy" }, "screenshot-min-order-copy": { "action": "screenshot", "filename": "evidence-min-order-empty", "note": "AC6 — submit button shows 'Order size must be at least $10' and is disabled while size is empty", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/ac7-enter-blocked-assert.json{ "title": "AC7 (negative) — Enter is blocked while submit button is disabled ({{side}} {{symbol}})", "description": "Self-runnable. Opens a fresh {{side}} order form on {{symbol}} (empty amount), focuses the amount input, dispatches Enter, asserts the order form is still mounted, route still carries mode=new, and the submit button is still disabled.", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "focus-input", "nodes": { "focus-input": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"amount-input-field\"] input');if(!i)return JSON.stringify({focused:false});i.focus();return JSON.stringify({focused:document.activeElement===i,preHash:window.location.hash});})()", "assert": { "operator": "eq", "field": "focused", "value": true }, "save_as": "ac7_neg_pre", "next": "press-enter" }, "press-enter": { "action": "key_press", "key": "Enter", "next": "assert-blocked" }, "assert-blocked": { "action": "eval_sync", "expression": "(function(){var b=document.querySelector('[data-testid=\"submit-order-button\"]');return JSON.stringify({formMounted:!!document.querySelector('[data-testid=\"perps-order-entry-page\"]'),hashStillNew:window.location.hash.indexOf('mode=new')!==-1,buttonStillDisabled:!!b?.disabled});})()", "assert": { "all": [ { "field": "formMounted", "operator": "eq", "value": true }, { "field": "hashStillNew", "operator": "eq", "value": true }, { "field": "buttonStillDisabled", "operator": "eq", "value": true } ]}, "save_as": "ac7_neg_proof", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/ac7-enter-submit-success.json{ "title": "AC7 (positive) — Enter key from focused amount input submits order and lands on market detail ({{side}} {{symbol}} ${{amount}})", "description": "Resilient idempotent subflow proving keyboard Enter submits the perps order form. Mirrors canonical `perps/open-long-position` pattern: pre-conditions gate, balance check via perpsGetAccountState, idempotent branch-position (if a {{symbol}} position already exists from a prior run treat as prior AC7 proof and skip resubmission), otherwise the setup opens a fresh {{side}} order form on {{symbol}} → switch to market → type {{amount}} → focus size input → press Enter → wait for market-detail redirect → state-wait on perpsGetPositions until the {{symbol}} position appears → verify on-screen position + direction regex + route lock. Self-contained: run standalone via `validate-recipe.js --recipe artifacts/recipe-flows/ac7-enter-submit-success.json`.", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" }, "sideLabel": { "type": "string", "default": "Long", "description": "Title-cased side label used in the direction regex and assertion messages" }, "amount": { "type": "string", "default": "15", "description": "USD notional amount — must clear $10 minimum for AC7" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled", "perps.ready_to_trade", "perps.sufficient_balance" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "gate-check-balance", "nodes": { "gate-check-balance": { "action": "eval_async", "expression": "(async()=>{var r=await stateHooks.submitRequestToBackground('perpsGetAccountState',[]);var bal=parseFloat(r?.totalBalance??'0');return JSON.stringify({ok:bal>0,bal:bal,reason:bal>0?'ok':'Insufficient perps balance ($'+bal+') — deposit or use funded testnet account'})})()", "assert": { "operator": "eq", "field": "ok", "value": true }, "save_as": "ac7_balance_gate", "next": "check-existing-position" }, "check-existing-position": { "action": "eval_async", "expression": "(async()=>{var ps=await stateHooks.submitRequestToBackground('perpsGetPositions',[]);ps=Array.isArray(ps)?ps:[];var hit=ps.find(function(p){return (p?.symbol||p?.coin||p?.asset||'').toString().toUpperCase()==='{{symbol}}'});return JSON.stringify({ok:true,hasPosition:!!hit,positionsCount:ps.length,symbol:hit?.symbol||hit?.coin||hit?.asset||null})})()", "assert": { "operator": "eq", "field": "ok", "value": true }, "save_as": "ac7_pre_position", "next": "branch-position" }, "branch-position": { "action": "switch", "cases": [ { "label": "position already exists — treat prior AC7 submit as proof, skip duplicate order", "when": { "field": "vars.ac7_pre_position.hasPosition", "operator": "truthy" }, "next": "verify-position-dom" } ], "default": "switch-market" }, "switch-market": { "action": "press", "test_id": "order-type-market", "next": "wait-market-mount" }, "wait-market-mount": { "action": "wait_for", "test_id": "amount-input-field", "timeout_ms": 5000, "next": "capture-form-wiring" }, "capture-form-wiring": { "action": "eval_sync", "expression": "(function(){var f=document.querySelector('[data-testid=\"perps-order-entry-page\"]');var b=document.querySelector('[data-testid=\"submit-order-button\"]');return JSON.stringify({formTag:f&&f.tagName,buttonType:b&&b.type,buttonForm:b&&b.form&&b.form===f,preHash:window.location.hash});})()", "assert": { "all": [ { "field": "formTag", "operator": "eq", "value": "FORM" }, { "field": "buttonType", "operator": "eq", "value": "submit" }, { "field": "buttonForm", "operator": "eq", "value": true } ]}, "save_as": "ac7_wiring", "next": "type-valid" }, "type-valid": { "action": "set_input", "test_id": "amount-input-field", "value": "{{amount}}", "next": "focus-input" }, "focus-input": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"amount-input-field\"] input');if(!i)return JSON.stringify({focused:false});i.focus();return JSON.stringify({focused:document.activeElement===i,preHash:window.location.hash});})()", "assert": { "operator": "eq", "field": "focused", "value": true }, "save_as": "ac7_focus", "next": "press-enter" }, "press-enter": { "action": "key_press", "key": "Enter", "next": "wait-market-detail" }, "wait-market-detail": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 15000, "next": "wait-position-in-state" }, "wait-position-in-state": { "action": "wait_for", "expression": "(async()=>{var ps=await stateHooks.submitRequestToBackground('perpsGetPositions',[]);ps=Array.isArray(ps)?ps:[];return ps.some(function(p){return (p?.symbol||p?.coin||p?.asset||'').toString().toUpperCase()==='{{symbol}}'})})()", "assert": { "operator": "eq", "value": true }, "timeout_ms": 45000, "poll_ms": 750, "next": "verify-position-dom" }, "verify-position-dom": { "action": "wait_for", "test_id": "perps-position-cta-buttons", "timeout_ms": 15000, "next": "wait-position-direction" }, "wait-position-direction": { "action": "wait_for", "expression": "(function(){var dir=document.querySelector('[data-testid=\"perps-position-leverage\"]');return !!dir && /{{sideLabel}}\\s+\\d+(?:\\.\\d+)?x/.test((dir.textContent||'').trim());})()", "assert": { "operator": "eq", "value": true }, "timeout_ms": 15000, "poll_ms": 500, "next": "enter-submit-proof" }, "enter-submit-proof": { "action": "eval_sync", "expression": "(function(){var dirEl=document.querySelector('[data-testid=\"perps-position-leverage\"]');var dirTxt=(dirEl?.textContent||'').trim();return JSON.stringify({leftOrderForm:!document.querySelector('[data-testid=\"perps-order-entry-page\"]'),onMarketDetail:!!document.querySelector('[data-testid=\"perps-market-detail-page\"]'),positionLive:!!document.querySelector('[data-testid=\"perps-position-cta-buttons\"]'),directionMatches:/{{sideLabel}}\\s+\\d+(?:\\.\\d+)?x/.test(dirTxt),onRoute:window.location.hash==='#/perps/market/{{symbol}}',directionText:dirTxt,postHash:window.location.hash});})()", "assert": { "all": [ { "field": "leftOrderForm", "operator": "eq", "value": true }, { "field": "onMarketDetail", "operator": "eq", "value": true }, { "field": "positionLive", "operator": "eq", "value": true }, { "field": "directionMatches", "operator": "eq", "value": true }, { "field": "onRoute", "operator": "eq", "value": true } ]}, "save_as": "ac7_redirect", "next": "screenshot-post-submit" }, "screenshot-post-submit": { "action": "screenshot", "filename": "evidence-enter-submit-redirect", "note": "AC7 — Enter from focused amount input (${{amount}}) dispatches form submit, order is placed, page redirects to market detail, and the new {{symbol}} position is visible. Idempotent: if a position already existed on entry the subflow treated that as prior AC7 proof and skipped resubmission.", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-flows/leverage-keyboard.json{ "title": "Leverage input — select-on-focus + ArrowUp/ArrowDown step ({{side}} {{symbol}})", "description": "Self-runnable. Opens a fresh {{side}} order form on {{symbol}}, then proves the leverage-input (1) auto-selects its current value on focus so typing replaces it, (2) steps up by 1 on ArrowUp, (3) steps down by 1 on ArrowDown. State-agnostic about the initial leverage value.", "inputs": { "symbol": { "type": "string", "default": "ETH", "description": "Market symbol" }, "side": { "type": "string", "default": "long", "description": "Trade side: long or short" } }, "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled" ], "setup": [ { "id": "open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "{{symbol}}", "side": "{{side}}" } } ], "entry": "capture-initial", "nodes": { "capture-initial": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');return JSON.stringify({hasInput:!!i,initial:i?parseInt(i.value,10):null});})()", "assert": { "all": [ { "field": "hasInput", "operator": "eq", "value": true }, { "field": "initial", "operator": "gt", "value": 0 } ]}, "save_as": "lev_initial", "next": "focus-leverage" }, "focus-leverage": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');if(!i)return JSON.stringify({focused:false});i.blur();i.focus();return JSON.stringify({focused:document.activeElement===i});})()", "assert": { "operator": "eq", "field": "focused", "value": true }, "next": "assert-select-on-focus" }, "assert-select-on-focus": { "action": "wait_for", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');var len=(i&&i.value||'').length;return JSON.stringify({selected:!!i && len>0 && i.selectionStart===0 && i.selectionEnd===len, len:len, start:i&&i.selectionStart, end:i&&i.selectionEnd});})()", "assert": { "operator": "eq", "field": "selected", "value": true }, "timeout_ms": 2000, "poll_ms": 100, "next": "press-arrow-up" }, "press-arrow-up": { "action": "key_press", "key": "ArrowUp", "next": "assert-arrow-up" }, "assert-arrow-up": { "action": "wait_for", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');var cur=i?parseInt(i.value,10):NaN;return JSON.stringify({current:cur,matches:cur==={{vars.lev_initial.initial}}+1 || cur==={{vars.lev_initial.initial}}});})()", "assert": { "operator": "eq", "field": "matches", "value": true }, "timeout_ms": 2000, "poll_ms": 100, "save_as": "lev_after_up", "next": "refocus-leverage" }, "refocus-leverage": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');if(!i)return JSON.stringify({focused:false});i.focus();return JSON.stringify({focused:document.activeElement===i,value:parseInt(i.value,10)});})()", "assert": { "operator": "eq", "field": "focused", "value": true }, "save_as": "lev_pre_down", "next": "press-arrow-down" }, "press-arrow-down": { "action": "key_press", "key": "ArrowDown", "next": "assert-arrow-down" }, "assert-arrow-down": { "action": "wait_for", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');var cur=i?parseInt(i.value,10):NaN;return JSON.stringify({current:cur,matches:cur==={{vars.lev_pre_down.value}}-1 || cur==={{vars.lev_pre_down.value}}});})()", "assert": { "operator": "eq", "field": "matches", "value": true }, "timeout_ms": 2000, "poll_ms": 100, "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }artifacts/recipe-demo.json{ "title": "TAT-2802 — Keyboard-first order entry UX (NARRATED DEMO RECIPE)", "description": "Single-pass demo flow for video capture. Opens one Long ETH order form, then walks through every AC inline with deliberate pauses (`wait`) so a human reviewer can read the screen between steps. Each AC ends with an evidence screenshot. Run with `--slow 800` for extra breathing room. NOT a CI gate — the validation bundle (`recipe.json` + `recipe-flows/`) is the authoritative pass/fail recipe.", "validate": { "workflow": { "pre_conditions": [ "wallet.unlocked", "perps.feature_enabled", "perps.ready_to_trade", "perps.sufficient_balance" ], "setup": [ { "id": "setup-prime", "action": "call", "ref": "perps/prime-perps-state" }, { "id": "setup-ensure-net", "action": "call", "ref": "perps/ensure-perps-network" }, { "id": "setup-open-form", "action": "call", "ref": "perps/open-order-form", "params": { "symbol": "ETH", "side": "long" } }, { "id": "setup-pause-intro", "action": "wait", "ms": 2000 } ], "entry": "ac1-size-autofocus-wait", "nodes": { "ac1-size-autofocus-wait": { "action": "wait_for", "expression": "!!document.activeElement && document.activeElement.closest('[data-testid=\"amount-input-field\"]') !== null", "assert": { "operator": "eq", "value": true }, "timeout_ms": 3000, "next": "ac1-size-screenshot" }, "ac1-size-screenshot": { "action": "screenshot", "filename": "demo-01-size-autofocus", "note": "AC1 — size input is auto-focused on order-form mount (caret visible inside amount-input-field)", "next": "ac1-size-pause" }, "ac1-size-pause": { "action": "wait", "ms": 1500, "next": "ac6-empty-button-eval" }, "ac6-empty-button-eval": { "action": "eval_sync", "expression": "(function(){var b=document.querySelector('[data-testid=\"submit-order-button\"]');return JSON.stringify({ text:(b?.textContent||'').trim(), disabled:!!b?.disabled });})()", "assert": { "all": [ { "field": "text", "operator": "eq", "value": "Order size must be at least $10" }, { "field": "disabled", "operator": "eq", "value": true } ]}, "next": "ac6-empty-screenshot" }, "ac6-empty-screenshot": { "action": "screenshot", "filename": "demo-02-empty-min-order-copy", "note": "AC6 — empty form: submit reads 'Order size must be at least $10' and is disabled", "next": "ac6-empty-pause" }, "ac6-empty-pause": { "action": "wait", "ms": 1500, "next": "ac5-below-type" }, "ac5-below-type": { "action": "set_input", "test_id": "amount-input-field", "value": "5", "next": "ac5-below-eval" }, "ac5-below-eval": { "action": "eval_sync", "expression": "(function(){var b=document.querySelector('[data-testid=\"submit-order-button\"]');return JSON.stringify({ text:(b?.textContent||'').trim(), disabled:!!b?.disabled });})()", "assert": { "all": [ { "field": "text", "operator": "eq", "value": "Order size must be at least $10" }, { "field": "disabled", "operator": "eq", "value": true } ]}, "next": "ac5-below-screenshot" }, "ac5-below-screenshot": { "action": "screenshot", "filename": "demo-03-below-min-disabled", "note": "AC5 — typing $5 keeps submit disabled with the same min-order copy (real-time validation)", "next": "ac5-below-pause" }, "ac5-below-pause": { "action": "wait", "ms": 1500, "next": "ac5-valid-type" }, "ac5-valid-type": { "action": "set_input", "test_id": "amount-input-field", "value": "15", "next": "ac5-valid-eval" }, "ac5-valid-eval": { "action": "eval_sync", "expression": "(function(){var b=document.querySelector('[data-testid=\"submit-order-button\"]');return JSON.stringify({ text:(b?.textContent||'').trim(), disabled:!!b?.disabled });})()", "assert": { "all": [ { "field": "text", "operator": "eq", "value": "Open long ETH" }, { "field": "disabled", "operator": "eq", "value": false } ]}, "next": "ac5-valid-screenshot" }, "ac5-valid-screenshot": { "action": "screenshot", "filename": "demo-04-valid-amount-enabled", "note": "AC5/AC6 — typing $15 enables submit, copy swaps to 'Open long ETH'", "next": "ac5-valid-pause" }, "ac5-valid-pause": { "action": "wait", "ms": 1500, "next": "ac2-blur" }, "ac2-blur": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"amount-input-field\"] input');i&&i.blur();return JSON.stringify({blurred:document.activeElement!==i});})()", "assert": { "field": "blurred", "operator": "eq", "value": true }, "next": "ac2-blur-pause" }, "ac2-blur-pause": { "action": "wait", "ms": 800, "next": "ac2-refocus" }, "ac2-refocus": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"amount-input-field\"] input');if(!i)return JSON.stringify({focused:false});i.focus();return JSON.stringify({focused:document.activeElement===i});})()", "assert": { "field": "focused", "operator": "eq", "value": true }, "next": "ac2-assert-select" }, "ac2-assert-select": { "action": "wait_for", "expression": "(function(){var i=document.querySelector('[data-testid=\"amount-input-field\"] input');var len=(i&&i.value||'').length;return JSON.stringify({selected:!!i&&len>0&&i.selectionStart===0&&i.selectionEnd===len,len:len});})()", "assert": { "field": "selected", "operator": "eq", "value": true }, "timeout_ms": 2000, "poll_ms": 100, "next": "ac2-screenshot" }, "ac2-screenshot": { "action": "screenshot", "filename": "demo-05-select-on-refocus", "note": "AC2 — blur & refocus selects the existing $15 in full so the next keystroke replaces it", "next": "ac2-pause" }, "ac2-pause": { "action": "wait", "ms": 1500, "next": "ac1-limit-switch" }, "ac1-limit-switch": { "action": "press", "test_id": "order-type-limit", "next": "ac1-limit-mount" }, "ac1-limit-mount": { "action": "wait_for", "expression": "!!document.querySelector('[data-testid=\"limit-price-input\"]')", "assert": { "operator": "eq", "value": true }, "timeout_ms": 2000, "next": "ac1-limit-autofocus" }, "ac1-limit-autofocus": { "action": "wait_for", "expression": "!!document.activeElement && document.activeElement.closest('[data-testid=\"limit-price-input\"]') !== null", "assert": { "operator": "eq", "value": true }, "timeout_ms": 3000, "next": "ac1-limit-screenshot" }, "ac1-limit-screenshot": { "action": "screenshot", "filename": "demo-06-limit-autofocus", "note": "AC1 — switching to Limit moves focus to limit-price-input (no mouse needed)", "next": "ac1-limit-pause" }, "ac1-limit-pause": { "action": "wait", "ms": 1500, "next": "ac1-market-switch" }, "ac1-market-switch": { "action": "press", "test_id": "order-type-market", "next": "ac1-market-autofocus" }, "ac1-market-autofocus": { "action": "wait_for", "expression": "!!document.activeElement && document.activeElement.closest('[data-testid=\"amount-input-field\"]') !== null", "assert": { "operator": "eq", "value": true }, "timeout_ms": 3000, "next": "ac1-market-screenshot" }, "ac1-market-screenshot": { "action": "screenshot", "filename": "demo-07-market-refocus", "note": "AC1 — switching back to Market refocuses the size input automatically", "next": "ac1-market-pause" }, "ac1-market-pause": { "action": "wait", "ms": 1500, "next": "lev-capture" }, "lev-capture": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');return JSON.stringify({initial:i?parseInt(i.value,10):null});})()", "assert": { "field": "initial", "operator": "gt", "value": 0 }, "save_as": "lev_initial", "next": "lev-focus" }, "lev-focus": { "action": "eval_sync", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');if(!i)return JSON.stringify({focused:false});i.blur();i.focus();return JSON.stringify({focused:document.activeElement===i});})()", "assert": { "field": "focused", "operator": "eq", "value": true }, "next": "lev-pause-1" }, "lev-pause-1": { "action": "wait", "ms": 800, "next": "lev-arrow-up" }, "lev-arrow-up": { "action": "key_press", "key": "ArrowUp", "next": "lev-after-up" }, "lev-after-up": { "action": "wait_for", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');var cur=i?parseInt(i.value,10):NaN;return JSON.stringify({current:cur,matches:cur==={{vars.lev_initial.initial}}+1});})()", "assert": { "field": "matches", "operator": "eq", "value": true }, "timeout_ms": 2000, "poll_ms": 100, "next": "lev-pause-2" }, "lev-pause-2": { "action": "wait", "ms": 800, "next": "lev-arrow-down" }, "lev-arrow-down": { "action": "key_press", "key": "ArrowDown", "next": "lev-after-down" }, "lev-after-down": { "action": "wait_for", "expression": "(function(){var i=document.querySelector('[data-testid=\"leverage-input\"] input');var cur=i?parseInt(i.value,10):NaN;return JSON.stringify({current:cur,matches:cur==={{vars.lev_initial.initial}}});})()", "assert": { "field": "matches", "operator": "eq", "value": true }, "timeout_ms": 2000, "poll_ms": 100, "next": "lev-screenshot" }, "lev-screenshot": { "action": "screenshot", "filename": "demo-08-leverage-keyboard", "note": "Leverage — select-on-focus + ArrowUp/ArrowDown step by 1", "next": "lev-pause-3" }, "lev-pause-3": { "action": "wait", "ms": 1500, "next": "ac7-refocus-size" }, "ac7-refocus-size": { "action": "eval_sync", "expression": "(function(){var w=document.querySelector('[data-testid=\"amount-input-field\"]');var i=w&&w.querySelector('input');if(!i)return JSON.stringify({focused:false});i.focus();return JSON.stringify({focused:document.activeElement===i,value:i.value});})()", "assert": { "field": "focused", "operator": "eq", "value": true }, "next": "ac7-pause-pre-enter" }, "ac7-pause-pre-enter": { "action": "wait", "ms": 1200, "next": "ac7-press-enter" }, "ac7-press-enter": { "action": "key_press", "key": "Enter", "next": "ac7-wait-detail" }, "ac7-wait-detail": { "action": "wait_for", "expression": "!!document.querySelector('[data-testid=\"perps-market-detail-page\"]') && window.location.hash==='#/perps/market/ETH'", "assert": { "operator": "eq", "value": true }, "timeout_ms": 15000, "poll_ms": 250, "next": "ac7-wait-position" }, "ac7-wait-position": { "action": "wait_for", "expression": "(async()=>{var ps=await stateHooks.submitRequestToBackground('perpsGetPositions',[]);ps=Array.isArray(ps)?ps:[];return ps.some(function(p){return (p?.symbol||p?.coin||p?.asset||'').toString().toUpperCase()==='ETH'})})()", "assert": { "operator": "eq", "value": true }, "timeout_ms": 15000, "poll_ms": 500, "next": "ac7-screenshot" }, "ac7-screenshot": { "action": "screenshot", "filename": "demo-09-enter-submit-success", "note": "AC7 — pressing Enter from the focused size input submits the order; UI lands on the ETH market detail page with the new Long position visible", "next": "ac7-pause-final" }, "ac7-pause-final": { "action": "wait", "ms": 2500, "next": "done" }, "done": { "action": "end", "status": "pass" } }, "teardown": [ { "id": "teardown-close-eth", "action": "call", "ref": "perps/close-position", "params": { "symbol": "ETH" } } ] } } }Note
Medium Risk
Medium risk because it changes perps order-entry submission/navigate/toast flow and adds new client-side validation that can block submits if miscomputed; most other changes are localized UI/keyboard UX tweaks with good test coverage.
Overview
Adds a keyboard-first UX across perps order entry and modals: primary inputs now
autoFocus, numeric fields select-all on focus, and key inputs (e.g., margin + TP/SL) can submit onEnterwhen valid; leverage input gains ArrowUp/ArrowDown stepping and swallowsEnterto avoid accidental outer-form submits.On
PerpsOrderEntryPage, the page becomes a native<form>withtype="submit"button, introduces real-time minimum market-order size gating (PERPS_MIN_MARKET_ORDER_USD) with localized submit-button copy (perpsMinOrderSize), and threads newautoFocusUsd/autoFocusLimitPrice+ USD placeholder/ref props throughOrderEntry/AmountInput(including refocus when toggling back to market).Submission and navigation are adjusted to avoid stuck toasts: in-progress toasts are emitted from the page (not via route state), navigation back to market detail uses
replace: trueand happens only after successful background RPC completion; header back behavior now falls back to market detail/default route when history can’t pop. Tests are expanded/updated to cover the new focus/keyboard behaviors, placeholders, and navigation expectations.Reviewed by Cursor Bugbot for commit 3ffa336. Bugbot is set up for automated code reviews on this repo. Configure here.