Skip to content

fix(portal): stop the phone recommending continue, and put the reading on the path to the answer - #82

Merged
abrichr merged 1 commit into
mainfrom
feat/portal-decision-structure
Jul 27, 2026
Merged

fix(portal): stop the phone recommending continue, and put the reading on the path to the answer#82
abrichr merged 1 commit into
mainfrom
feat/portal-decision-structure

Conversation

@abrichr

@abrichr abrichr commented Jul 27, 2026

Copy link
Copy Markdown
Member

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-cloud is untouched and is owned by a concurrent
agent.

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 depth
at 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.js mapped allowed_actions[0] to class="primary" and styles.css:169
painted it filled accent. Flow puts continue at index 0 exactly when the step
has enough contract for the engine to re-verify one
(attended.py:_allowed_actions) — a safety property laundered into a UI
recommendation, 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 .primary class is gone from the button
markup 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. OpenAdapt
re-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 in app.js, since the shell already owns
fallback copy)" — and renders it last before the answers:

OpenAdapt re-checks what it can measure and stops again if that fails. It
cannot check whether you actually looked. Answer from the live application,
not from this page.

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 alt says the same, and a line under the image states that it is a retained
picture, 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's
measured 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. #gate is a 1px mark at the
very 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_class is now
rendered above the question for consequential and irreversible, and the
two-sided sentence is the last thing before the answers. unknown renders
nothing, 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)

  • The receipt discipline in interpretReply — an accepted tap is never
    converted into success; network failure yields "The result is uncertain. Do
    not answer again."; an unrecognised (state, reason_code) yields an
    explicitly unknown outcome, never a refusal.
  • The consequence subtitles ("This run only" / "Changes future runs" /
    "Hands this to someone else").
  • The will_recheck block and the refusal copy.
  • Nothing defaulted, pre-selected, auto-submitted, or remembered; no "don't
    ask again".
  • The fixed bar's position and its measured height.

Deliberately out of scope

  • R9 (one wire action, one sworn statement — "I fixed it" vs "I checked it"
    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.
  • R5's second half — "Effect tier observed — of 3" is still rendered, and
    the dashboard's effectProofLine() copy still needs porting.
  • Q4's expiry legibility — still a raw microsecond ISO-8601 string. Review
    ranks it low priority; the dashboard's localised expiry is the thing to port.
  • R4 (a reject action) — engine change, and the review sequences it after
    R1–R3 precisely because R1 removes most of the pressure it compensates for.
  • R6, R7, R8 — priced above this change and, per §7, should wait on the halt
    rate.

For the Cloud surface (not touched here)

  • R1 applies identically and is blunter there:
    action === 'verify_and_resume' ? 'btn primary' : 'btn'
    (HumanDecisionCard.tsx:268) hardcodes the accent to continue by identity.
  • R2 is more urgent there — the assurance sentence is below the fold and
    behind the collapsed "Evidence and safety details" <details>.
  • R9 and the delivery_state gap are already owned by the concurrent agent.
  • R3 does not apply: the dashboard shows no frame and §3.5.8 / Part 5 feat: rewire engine for hosted loop (auth, ingest push, flow bridge) #11 say
    that asymmetry is correct. Do not add one.
  • The structural gate does not apply either. §3.5.8: depth above the action
    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.
  • Never add batch actions (§3.5.5).

Verification

Driven against a real halt, not read. make_halt.py produced a real recording,
compile, effect-contract mining, policy certification, run-gate admission and a
real drift halt; the shipped PortalService served it in loopback mode with
Flow PR #290 (which is where presentation.halt comes from — against released
Flow the halt block is empty, per §Q1's caveat); a real Chromium at Playwright's
iPhone 15 profile did the real one-use QR pairing and photographed the result.

Measured in the page, before → after:

scroll 0 end of page
before 3 answers, 1 filled primary 3 answers, 1 filled primary
after 0 answers, gate prompt 3 answers, 0 primary

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 correctly
renders nothing in these captures; the consequential and irreversible
renderings are pinned by unit test instead.

Tests: 61 vitest (24 in portalShell.test.ts, +10 new behavioural tests
covering 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. ruff and
tsc --noEmit clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

…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
@abrichr
abrichr merged commit 33827de into main Jul 27, 2026
15 checks passed
@abrichr
abrichr deleted the feat/portal-decision-structure branch July 27, 2026 23:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant