Skip to content

feat(portal): one swipe card per decision for paused runs and saves - #212

Merged
abrichr merged 3 commits into
mainfrom
feat/portal-swipe-cards
Oct 18, 2026
Merged

abrichr merged 3 commits into
mainfrom
feat/portal-swipe-cards

Conversation

@abrichr

@abrichr abrichr commented Oct 18, 2026

Copy link
Copy Markdown
Member

Don't merge yet. Richard reviews the look first.

Supersedes #208 and #209 (left open for reference). Includes #207's save-approval relay (fast-forwarded in), so the diff shows it until #207 merges. Flow's save routes come from OpenAdaptAI/OpenAdapt#1234; presentation.fields comes from a separate small Flow PR. Against an older Flow the phone shows the screenshot instead of fields.

What changes

The phone shows one card per decision, for paused runs and for saves waiting for approval. It's built from the approved prototype.

  • Buttons first, swipe as a shortcut. Two visible answers (at least 44 px) plus ⋯ for every other allowed answer, the full screenshot and details. Swiping right or left does the same as the two buttons (WCAG 2.5.1 and 2.5.7), and so do the arrow keys.
  • Five seconds to undo. The answer is held on the phone, then sent. Undo sends nothing. If the phone goes offline, the page closes, or the request expires during the hold, the answer is dropped. Nothing is queued.
  • Can't be undone means match and hold. For risk_class: irreversible, a swipe or tap only arms. The person taps one fact from the card (a date first, then any value with a digit, else the step number) and holds for 1.2 s. There's no undo after the hold.
  • Values first, screenshot behind a tap. A save shows presentation.fields in large monospace type, with replaced values struck through. Without fields, the card shows the screenshot itself. A malformed list is dropped whole.
  • Emphasis (EMPHASIS_ENABLED): the single continuing answer is filled only when the step can be undone.
  • Dark first, light from the system setting, with reduced motion respected. All literal colours are canonical tokens (dark from the inset set). The prototype's blue approve is now the brand green --accent-verified / --inset-ok, because the palette test requires canonical values.
  • The old scroll-to-the-end gate is gone. One card fits on screen, and the long explanations moved to ⋯.

#209 bugs fixed

  1. A bare 404 no longer means "gone" or "no saves". Only Flow's closed codes (request_not_waiting, request_unknown, already_answered) mean gone. Any other 404 shows "not attended", and a 404 save list makes the empty screen say saves aren't reaching the phone.
  2. 401 and 403 (including a refused CSRF or origin on an answer) go back to pairing.
  3. Receipts show the receipt's decided_at, not the phone's clock.
  4. The save frame must be image/png with a PNG signature and a SHA-256 equal to presentation.screenshot_sha256. If not, there's no picture and no approval, though Don't save still works.
  5. A locked button (Save with no picture, the hold before a match) can't be re-enabled by the online/offline sync.
  6. request_expired is matched exactly instead of with a regex.

Contract, unchanged

Bearer token on every call. POSTs carry Origin, JSON content type and X-OpenAdapt-Portal-CSRF, and the phone refuses to send a body over 16 KiB. Save answers are exactly action, task_digest, task_signature, plus decline_reason (wrong_values, wrong_record, screen_problem) on reject. Paused runs keep their /api/portal/tasks routes and their six-field body. Only signed allowed actions are shown, and nothing goes into notifications.

Screens (synthetic data, 390x844)

Save, dark Save, light Paused run Can't be undone, armed
Swipe right Undo window Decline reasons Screenshot only
No picture Holding All clear + receipt Not attended

All 29 states in each theme: dark, light. The individual files are on the pr-assets/portal-swipe-cards branch, which isn't for merging.

scripts/capture_portal_scenarios.py now drives the real shell through a stubbed transport with real taps, drags and holds, and writes every state in dark and light at 390x844. The form in the screenshot is a synthetic page the script renders.

Tests

  • src/portalShell.test.ts was rewritten (52 tests) on the real index.html with fake timers. It covers the exact save and run bodies and headers, undo, offline drop, expiry during the hold, the 16 KiB refusal, closed decline reasons, fields vs. screenshot fallback, digest mismatch, non-PNG, swipe and keyboard, match and hold (short hold sends nothing), decided_at, every 404 case, 401 and 403 to pairing, reconciliation proof, halted, uncertain, unknown and legacy receipts.
  • tests/test_portal/test_shell.py: no inline style= (CSP), reduced motion, 44 px targets, dark by default.
  • tests/test_portal/test_shell_palette.py: dark and light tokens against canonical tokens, plus every literal hex is canonical.
  • Local: pytest tests/ 1397 passed, 2 skipped. npm run test:ui 371 passed. tsc --noEmit clean. ruff check clean.
  • Checked by hand in a browser at 390x844, dark: drag right, undo window, approve, advance to the next card.

🤖 Generated with Claude Code

abrichr and others added 3 commits October 17, 2026 23:16
Allowlist Flow's /api/save-approvals routes (list, signed task detail, exact
screenshot, decision) behind /api/portal/save-approvals with the same session,
origin and CSRF checks as attended decisions. Desktop forwards the phone's
answer verbatim and adds no authority. Contract documented in
docs/DECISION_PORTAL.md for the portal UI work to build on.
Replaces the text-heavy card with one card per decision, dark first with a
light theme. Buttons stay the primary way to answer; swiping right or left is
a shortcut for the same two answers. Answers are held for five seconds with
Undo and nothing is queued offline. An irreversible step arms on swipe and
needs a matched fact and a 1.2 s hold. Saves show presentation.fields when
Flow sends them and the screenshot otherwise; Save is offered only after the
frame is a PNG whose SHA-256 matches screenshot_sha256.

Also fixes the save-approval bugs from #209: a bare 404 means the computer
isn't attending (only Flow's closed codes mean gone), 401 and 403 go back to
pairing, receipts use the receipt's decided_at, and the frame is checked
against its digest. POST bodies are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abrichr
abrichr merged commit 24e9d00 into main Oct 18, 2026
17 checks passed
@abrichr
abrichr deleted the feat/portal-swipe-cards branch October 18, 2026 16:09
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