fix(portal): stop the phone recommending continue, and put the reading on the path to the answer - #82
Merged
Merged
Conversation
…g on the path to the answer The decision surface discouraged rubber-stamping in its copy and encouraged it in its structure. A complete tappable decision fitted in one viewport with zero evidence consulted, and the action bar is `position: fixed`, so there was no scroll depth at which reading was required. Four changes, in the order they matter. 1. No answer is rendered as the recommended one. The filled accent followed `allowed_actions[0]`, and Flow puts `continue` at index 0 exactly when the step has enough contract for the engine to re-verify a continue. That is a statement about what the engine can CATCH, displayed to the operator as a recommendation -- emphasising continue precisely where a wrong answer is catchable, which is the inverse of what a person needs and a distinction they cannot see. Which option looks like the default is the largest measured lever on this kind of screen, so it must not be set by a predicate that does not mean "recommended". No field here means that, so nothing is emphasised. 2. The assurance sentence names both halves of the boundary, above the actions. "Your answer does not mark the run verified" says only that the machine will catch you; it does not say what the machine cannot check, which is whether you looked at all. It also sat below the buttons, so it was read after the decision if at all. The shell now states both halves and states them last before the answers. 3. The retained frame is named as history. The disclosure said "View current screen" over a retained artifact with no age and no refresh, on a product whose thesis is that the screen is not the record. It is now "Screen when OpenAdapt stopped", carries how long ago that was, and says in one line that it does not update and the live application is what to look at. The frame stays: it is the only re-entry cue this operator has. There is deliberately no refresh control -- the runner does not re-observe on demand, and a refresh that re-fetched the same artifact would manufacture the exact illusion this wording removes. 4. The action bar keeps its position and changes its gating. `#gate` is the last element in the document, so the answers become available only once the operator has reached the end of what the engine had to say; until then the bar carries a non-tappable prompt and no shortcut past the content. When the engine had little to say the gate is already on screen and the answers appear at once. It fails open where `IntersectionObserver` is unavailable. `risk_class` is now rendered above the question for `consequential` and `irreversible` halts. Flow computes and seals it and the shell dropped it; it is the one field that says what a wrong answer costs, and it is half of what belongs above the action. `unknown` renders nothing, so the row cannot become wallpaper. Unchanged on purpose: the receipt discipline in `interpretReply`, the consequence subtitles, the `will_recheck` block, the refusal copy, the fixed bar's position and measured height, and the fact that nothing is defaulted, pre-selected, auto-submitted, or remembered. Verified by driving a real halt through the shipped PortalService at an iPhone 15 profile: before, 3 answers and 1 filled primary at scroll 0; after, 0 answers at scroll 0 and 3 equally-weighted answers at the end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the structural recommendations from the human-factors review of the
phone decision surface (private,
.private/MOBILE_UX_REVIEW_2026_07_27.md).Phone half only —
openadapt-cloudis untouched and is owned by a concurrentagent.
The review's verdict is the brief: "The current design discourages
rubber-stamping in its copy and encourages it in its structure, and structure
wins." A complete tappable decision fitted in one viewport with zero evidence
consulted, and the action bar is
position: fixed, so there was no scroll depthat which reading was required. Nothing here adds undifferentiated friction — the
review is explicit (§2.3) that a fixed tax is what habituation eats.
What changed
R1 — continue is no longer the visual primary
app.jsmappedallowed_actions[0]toclass="primary"andstyles.css:169painted it filled accent. Flow puts
continueat index 0 exactly when the stephas enough contract for the engine to re-verify one
(
attended.py:_allowed_actions) — a safety property laundered into a UIrecommendation, and inverse to the truth. The review's recommendation is "give
all actions equal visual weight… derive the emphasis from something that
actually means 'recommended' — and note that no such field currently exists,
which is itself the answer." So: the
.primaryclass is gone from the buttonmarkup and the rule is deleted from the stylesheet, with a comment saying why it
must not come back. The bar's contrast is carried by a lifted surface applied
equally to every button, not by one accent. The action list itself is still
rendered verbatim from the signed task — nothing added, removed, or reordered.
R2 — the assurance sentence is two-sided and sits above the actions
presentation.assurance("Your answer does not mark the run verified. OpenAdaptre-checks the live state…") is one-sided and sat below the buttons in reading
order. The shell now owns the sentence — which R2 explicitly authorises, "one
string in
human_decisions.py(or inapp.js, since the shell already ownsfallback copy)" — and renders it last before the answers:
Flow's one-sided string is no longer printed beside it; printing both would
state the reassuring half twice and the limit once.
R3 — the frame is named as history
Summary is now "Screen when OpenAdapt stopped" (was "View current screen"),
the
altsays the same, and a line under the image states that it is a retainedpicture, that it does not update, and that the live application is what to look
at. Age is rendered coarsely ("about 14 minutes ago"). No refresh control —
the runner does not re-observe on demand and a refresh that re-fetched the same
artifact would manufacture the exact illusion of liveness this fix removes. The
frame stays: it is the only re-entry cue this operator has (§Q3).
Caveat, per R3: there is no per-artifact capture timestamp in the projection,
so the age is derived from
task.created_at(the pause capability's issue time,i.e. when the run stopped) and is worded as the age of the stop, never as a
claimed capture time. A real capture timestamp is the small Flow addition R3
mentions.
The structural fix — Q1, §2.2, Part 5 #7
Part 5 #7 is explicit: the fixed bar's position and
syncActionBarSpace'smeasured height are correct mobile engineering — "change the gating, not the
position." §2.2(1) names the defect: "all the risk-relevant information is
present and none of it is on the path to the action."
So the position is unchanged and the gating is new.
#gateis a 1px mark at thevery end of the document. Until it has been reached the bar carries a
non-tappable prompt ("Read this decision to the end — the answers are below
it.") and there is deliberately no jump-to-answers shortcut. Reaching it swaps
in the answers. Where the engine had little to say the gate is already on screen
and the answers appear at once — fast when the answer is obvious, structurally
slower in proportion to the evidence, which is the review's actual ask rather
than a per-decision tax. It fails open without
IntersectionObserver.Q1's other half — "volume above the action is a budget… two things belong above
the action and are not there" — is also implemented:
risk_classis nowrendered above the question for
consequentialandirreversible, and thetwo-sided sentence is the last thing before the answers.
unknownrendersnothing, so the row cannot become the ignorable neighbour of §Q1/R5.
When the bar grows from a one-line prompt to three two-line buttons it now takes
the scroll position down by exactly the growth, so it cannot swallow content the
operator was reading — the same class of defect as #79's occluded outcome line.
Explicitly not changed (Part 5)
interpretReply— an accepted tap is neverconverted into success; network failure yields "The result is uncertain. Do
not answer again."; an unrecognised
(state, reason_code)yields anexplicitly unknown outcome, never a refusal.
"Hands this to someone else").
will_recheckblock and the refusal copy.ask again".
Deliberately out of scope
vs "Corrected"). The concurrent Cloud agent owns R9; picking a phone string
unilaterally risks landing a different divergence. The review's preferred
shape is "I've prepared it — re-check the live screen". This still needs
the phone half.
the dashboard's
effectProofLine()copy still needs porting.ranks it low priority; the dashboard's localised expiry is the thing to port.
R1–R3 precisely because R1 removes most of the pressure it compensates for.
rate.
For the Cloud surface (not touched here)
action === 'verify_and_resume' ? 'btn primary' : 'btn'(
HumanDecisionCard.tsx:268) hardcodes the accent to continue by identity.behind the collapsed "Evidence and safety details"
<details>.delivery_stategap are already owned by the concurrent agent.that asymmetry is correct. Do not add one.
matters on the phone and barely at all at a desk; the dashboard's fix is R11's
triage layer (compact rows, age, risk), not a scroll gate.
Verification
Driven against a real halt, not read.
make_halt.pyproduced a real recording,compile, effect-contract mining, policy certification, run-gate admission and a
real drift halt; the shipped
PortalServiceserved it in loopback mode withFlow PR #290 (which is where
presentation.haltcomes from — against releasedFlow the halt block is empty, per §Q1's caveat); a real Chromium at Playwright's
iPhone 15profile did the real one-use QR pairing and photographed the result.Measured in the page, before → after:
Page height 2.7 viewports (1709px / 659px CSS), matching the review's own
measurement of the same screen.
Screenshots (private):
.private/mobile_demo/shots/2026-07-27-hf-review-{before,after}-0{1..5}-*.png— queue, decision viewport at scroll 0, full page, bottom viewport, and the
expanded frame disclosure.
The halt used here has
risk_class: unknown, so the stakes block correctlyrenders nothing in these captures; the
consequentialandirreversiblerenderings are pinned by unit test instead.
Tests: 61 vitest (24 in
portalShell.test.ts, +10 new behavioural testscovering equal button weight, the two-sided sentence and its position, the frame
label/age/role line and the absence of a refresh control, the stakes block and
its suppression on
unknown, and the gate — prompt, no shortcut, opening,answering after it opens, and failing open). 126
tests/test_portal.ruffandtsc --noEmitclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM