Repository navigation
Inbox mobile: the surface mode (S1) - #1777
Merged
Merged
Conversation
backnotprop
force-pushed
the
feat/mobile-s1-surface
branch
from
October 8, 2026 05:34
2833a50 to
a01676c
Compare
One self-contained file a phone's shell loads (apps/hook/dist/surface.html): Viewer with its toolstrip, HtmlViewer with pins, DiagramViewer and the guide, no network, and the versioned bridge of contract section 5. The composer is the shell's, through additive ui seams (onHostDraft, stepPin, hideRedline). Proved in WebKit against a compiled Inbox.
A frame's link reaches the shell only after a real tap (navigator.userActivation), so an agent page cannot forge the viewer's link-click; the toolstrip and the guide's controls sit above the document. Both proved in the WebKit spec.
The surface never forwards a link from an agent's HTML page: the page can forge the frame's message and its taps never reach the main frame, so no gate in the surface can tell them apart (the userActivation gate failed CI). Contract section 5 says the shell decides those navigations (M2); markdown links keep the bridge link.
The first pin raced the bridge's ready on the CI runner (no pin within 15 s at 4c4bdf0); each mode switch now waits for the page body's pinpoint mark before the tap.
… bridge does not arm
A passive effect could register the message listener after a fast srcdoc frame had already posted the bridge's ready; the lost ready left the bridge with no input method or mode (the surface's HTML proof on the Linux WebKit runner). HtmlViewer and useHtmlAnnotation now listen from a layout effect.
backnotprop
force-pushed
the
feat/mobile-s1-surface
branch
from
October 8, 2026 06:43
b892f17 to
04b9360
Compare
1 task
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.
The story
A phone's native shell needs Plannotator's own viewers without a website in a frame. This PR builds
apps/hook/dist/surface.html, one self-contained file that mounts only the attachment viewers and the guide, with no network, and the versioned bridge the iPhone app talks to it over. The composer stays native: the viewers hand their drafts to the shell through new additive ui seams, and the shell answers with the saved annotation.What changed
packages/core/inbox-surface-bridge.ts(re-exported by@plannotator/core/inbox-types, so core's export map is unchanged): every message of contract section 5 both ways,INBOX_SURFACE_BRIDGE_VERSION = 1, andreadSurfaceShellMessage(envelope, version, what the surface needs to draw). The surface's end:packages/inbox/surface/bridge.ts(window.plannotatorSurface.receivein,window.webkit.messageHandlers.plannotatorSurface.postMessageout).packages/inbox/surface/:SurfaceApp.tsx(theme and Dynamic Type from the shell, settings in memory, diffs unified and wrapped),SurfaceDocument.tsx(markdown and text throughViewerwithAnnotationToolstrip; HTML throughHtmlViewerwith pins; diagrams throughDiagramViewer, lazy inSurfaceDiagram.tsx),SurfaceGuide.tsxwith the readerpackages/inbox/guide/SurfaceGuideReader.tsxbehind#surface-guide-reader(lazy): the sections with ticks and Continue (6.1), then oneGuideSectionCardper screen with Previous, Reviewed and Next (6.2).surface.css(sf-classes, the system face on Plannotator's tokens).apps/inbox:surface.html(CSPdefault-src 'none',connect-src 'none'; an HTML page's folder fromplannotator-asset:),surface.tsx,surface.css,vite.surface.config.ts, scriptbuild:surface;build:hookcopies it toapps/hook/dist/surface.htmlbesideinbox.html.tsc --noEmit -p apps/inbox/tsconfig.jsonand-p packages/inbox/tsconfig.jsonnow cover the new files.packages/ui/HANDOFF.md"The composer handed to the host ... (unreleased)"):HostDraft(types) andonHostDraftonViewer/useAnnotationHighlighter,HtmlViewer/useHtmlAnnotation, andDiagramViewer(DiagramHostDraft): the draft goes to the host, no toolbar or composer is drawn.ViewerHandle.stepPin?(HtmlViewer) and the bridge-script messageplannotator-bridge-step-pin: Parent and Child for a pinned draft.AnnotationToolstriphideRedline(4.1 draws Select, Pinpoint, Markup, Comment).HtmlVieweranduseHtmlAnnotationlisten for frame messages from a layout effect (a lostready, blocker 3 below).HtmlViewer'sannotateModeActivealready is one.adr/implementation/inbox-mobile.mdsection 5, edited first:open_section(shell to surface) andsection(surface to shell) added;linkcomes only from the surface's own markdown, HTML-page links being a navigation the shell decides (review blocker 1); plus what apin's and adraft's label is, when a nullselectionis sent, and the surface's CSP. Reason: 6.1 and 6.2 put the guide's title ("02 of 04") and its close or back button in the shell's bar but Continue, Previous, Reviewed and Next in the document, so each side has to tell the other where the guide is.tests/e2e/surface.spec.ts(WebKit), added totests/e2e/playwright.config.ts;.github/workflows/inbox-e2e.ymlinstalls and caches WebKit beside Chromium (plannotator-ops: one more browser in the existing job, no new job).tests/entry-assets.test.ts: the surface's CSP pinned, its reader and diagram engine held lazy, its bundle in the no-WASM list.AGENTS.md: Project Structure lines and "The surface" under Plannotator Inbox.Record sections it makes real
The surface's half of 3.6 and 4.1 to 4.4 (the document and its marks: selection, pinpoint, HTML pins with Annotate or Interact and Parent or Child, a diagram node), and 6.1 and 6.2 (sections, ticks, Continue, one section with Previous, Reviewed, Next); light and dark; Dynamic Type. The bars, sheets, the changed line and the annotations sheet are the shell's (M2, M4).
Proofs
bun run test:e2e:inbox(orbunx playwright test -c tests/e2e/playwright.config.ts surface.spec.ts) afterbun run build:review && bun run build:hookand the compiled binary at.local/plannotator. Playwright 1.63.0 WebKit loads the builtsurface.htmlfrom a file URL as the main frame; a compiledplannotator inbox --backgroundunder a temp data dir; Claude Code, Codex, OpenCode and Pi write a markdown plan, the "Low Tide" ticket page, a Mermaid flow and a guided review throughplannotator inbox mcp(scripts/inbox-sim.ts). The spec plays the shell: it reads every byte through the window routes (the device door is P1's), rewrites the page's base ontoplannotator-asset://inbox/..., saves drafts and ticks through the window's routes and answerscommit_annotation.window.webkit.messageHandlers.plannotatorSurfaceexists in every frame, as WKWebView injects it, and records which frame posted.readywithbridge: 1; av: 2message answerserrorbridge_version.fetchfrom the surface fails underconnect-src 'none'.selectionwith its quote and an empty-text draft; a press elsewhere sends the nullselectionand the pending mark goes; saved and committed, the comment mark is drawn; a tap on it sendsannotation; a Pinpoint tap sendsdraft(kindblock);remove_annotationcancels it; a link goes to the shell aslinkand the page does not navigate.pinwith label and selector);step_pinparent twice and child once lands back on the button; saved, its marker is drawn in the page; Interact lets the page's own click run and sends no pin; Annotate pins again.parent.plannotatorSurface.receiveandtop.webkitthrowSecurityError; its own frame's handler post arrives marked as a child frame (the shell drops it); a bridge-shapedpostMessageto the surface relays nothing; the viewer's ownplannotator-bridge-link-clickforged at load (anhttps:and amailto:href) sends nolink, and neither does a real tap on the page's own link.draft{ kind: "node", label: "Pick a host (node E)" }; saved, its badge shows.reviewed), Continue (section1 of 2), Reviewed on the section page,open_sectionnull back to the list; the message'sguide_reviewedin the store reads[true, true].set_appearance; attext_scale1.6 the same paragraph reflows to more than 1.5 times its height, and every toolstrip button is still what a tap at its centre hits.PNGs light and dark, 393 by 852 at 3x:
/Users/ramos/workspaces/research/operational/mobile-proofs/S1-2026-10-08/(withindex.html), and the CI artifactinbox-window-proof.Gates run
bun run typecheck(with the new files underpackages/inboxandapps/inbox;tsconfig.guide.jsonreports nothing inpackages/inbox).bun test packages apps/hook tests scriptsunder a temp data dir: 5821 pass, 0 fail. The one failure of a first run was the fresh worktree beforeapps/pi-extension/vendor.shhad writtenapps/pi-extension/generated/(the reviewer reproduced it), not this PR.DOM_TESTS=1 bun test --isolateover the 217 files of test.yml's DOM step: 1550 pass.tests/entry-assets.test.ts,tests/favicon-surfaces.test.ts: pass.bun run --cwd packages/ui smoke:package: pass.bun run --cwd apps/guides-show build:viewer && check:manifest: in sync (no class added to the guide chain).bun run check:release-version: consistent.bun run test:e2e:inbox: 65 passed, 1 skipped (P1's phone proofs, the window's Chromium proofs and the 8 surface proofs in WebKit).Review blockers, fixed (review: research/reviews/REVIEW-PLANNOTATOR-MOBILE-S1-2026-10-08.md)
An agent's page could forge
plannotator-bridge-link-clickand reach the shell'slinkwith no tap. A first fix gated onnavigator.userActivationand failed CI ata01676c67: the forged post fired about 5 s after the last driven tap and rode the still-active flag (a weak gate, not a flaky test). A tap inside the agent's frame never reaches the main frame (proved in WebKit), so the surface cannot tell a real tap from a forged one. Now the surface never forwards a link from an agent's HTML page (noonOpenLink); the shell decides that navigation in M2 (WKNavigationDelegate,.linkActivated,SFSafariViewController), written into contract section 5. Markdown links keeplink. Proof 5 asserts at once that neither the forgery nor a real tap in the page sends alink; proof 3 keeps the markdown link.The document drew over the toolstrip (Viewer's layer is
z-50)..sf-toolstrip,.sf-gfootand.sf-gnavtakez-index: 100; proof 8 asserts the buttons are the hit target.Found by CI after the link fix (not a review item): the HTML proof failed on the Linux WebKit runner because the frame's
readyreached the main window beforeHtmlViewerlistened (the failure report showed the main window receivedplannotator-bridge-readyand the frame never got its input method or mode). A passive effect registered the listener after a fast srcdoc frame had posted.HtmlVieweranduseHtmlAnnotationnow register their frame-message listeners in a layout effect, in the commit that inserts the iframe; noted inHANDOFF.md. The phone's WKWebView would hit the same race. The spec waits for the frame's pin mark after each mode switch and reports what each side received if it ever does not arm.Leaves out
The native shell, any list or thread UI, Pencil, multi-block selections (the brief). Also, each named for a later step:
open_attachmentcarries no agent name, so it is the shell's (M2).http(s)andmailtogo to the shell aslink. Links in an agent's HTML page go to the shell as navigations in M2 (contract section 5), which also needs a ui seam so the viewer's bridge lets the frame navigate.plannotator-asset:scheme handler, which a browser test cannot register; the proof page is self-contained, and M2 proves assets on the device.div.btnx; the selector rides beside it if the sheet wants the tag and class.plannotator-bridge-selectioncan open the shell's composer with its own label and no tap (the same activation check fits); (2)open_attachment.focusis not proved and likely does not scroll on markdown (the scroll runs before the stored marks are painted), which M2's "3 annotations" sheet needs; (3) 6.2's diff box has blank space above and below the diff at phone size; (4) the agent page insurface.spec.tsfetches127.0.0.1:1, which proves nothing about the CSP: point it at the live Inbox; (5) theSurfacePinandHostDraft.labelcomments still saydiv.btnx, the contract says "Button"; (6)inbox-e2e.ymldoes not listapps/hook/package.json, which now carries the surface's build step.Deviations from the record or the contract
open_sectionandsectionadded, the reason above; edited in this PR.GuideSectionCard(title, position, overview, file chips, then the diffs), so the file chips and the "02 / 02" position sit inside the card as on desktop; the card's own Reviewed checkbox is hidden (showReviewed={false}) so the thumb bar's Reviewed is the only one, and a reviewed section does not collapse.Limits
None added. The bridge has no caps; the surface adds none.
Depends on
Contract on main (PR 1775). Rebased onto main after P1 (PR 1776), its follow-up (PR 1779) and P2 (PR 1780) merged; the conflicts were only in
tests/e2e/playwright.config.tsand theinbox-e2e.ymlheader comment, both resolved to list every spec. The proof uses the window routes; the device door maps the same handlers.