mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* test(mobile): pin the terminal WebView document byte for byte The document is already pinned as a digest, which says whether the emitted bytes moved and nothing about where. C7.1 moves the hand-written script inside it into modules the web page can import and rebuilds the document from them, and the claim that has to hold through every one of those commits is that the native screen kept the document it had. A digest cannot be the instrument for that: it fails as two hexadecimal strings. So the document is also committed as itself. The fixture is generated by `scripts/build-terminal-document-fixture.mjs`, never pasted, and the test rebuilds the comparison through that script's own substitution rather than restating it, so a fixture written by one rule and read by another cannot agree with itself. The generated xterm engine is stored as two placeholders. It is already covered by the digest test, postinstall regenerates it from whatever xterm the lockfile holds, and inlining it would put 612 KiB of vendored bytes into the file whose job is to isolate hand-written changes. Two further cases keep that from becoming a hole: the placeholders must each appear exactly once and the engine must not appear at all, and the restored document must equal the real one. Regenerating the fixture is a review event. It is only correct when the emitted document was meant to change, and the diff in that commit is the evidence. Red-first: flipping one character inside a comment in `write-queue.ts` fails both identity cases with a one-line diff naming the comment, where the digest test reports a hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): compare two terminal documents as programs, not as bytes The C7.1 flip commit moves the document's 57 reassigned variables onto a scope object, because a variable assigned across ES modules is a syntax error, and every read and write of them gains a qualifier. The ruling asks that the review of that commit be a test rather than a 515-line read. This is that test's instrument. It cannot be a byte comparison. Once the script's source is modules, `oxfmt` owns its style, and the repository's style has no semicolons where the hand-written document has one on nearly every line. A byte diff would therefore be dominated by changes that are not the refactor, which is the opposite of what the reviewer needs. So the comparison is over tokens: semicolons are excluded for the same reason they moved, comments never reach the stream, and one difference is allowed — `name` becoming `<qualifier>.name`, three tokens for one — which it counts and reports. It is stricter than "it still runs": a reordered statement, a changed literal, a dropped operator, a renamed local and a qualifier under the wrong object name all diverge, each reported with the token index and both sides. Acorn carries `value` on its tokens but does not declare it, so the field is read through a narrowing check rather than asserted onto the declared type. Red-first, by mutation: dropping the qualifier-name check fails the case that names it; removing the leftover-token check fails the dropped- and added-statement cases; treating semicolons as significant fails the three cases that depend on ignoring them. The acceptance case runs on the real 2,758-line script rather than on a fixture, so the instrument is known to survive everything the document actually contains. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count each normalisation the move makes, separately Measured while extracting the first group: the document's ES5 style is not a style this repository's own rules permit. `curly` braces 279 brace-less if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446 `var` declarators become `const`, `let` or a scope field. Those rewrites land before the qualifier is considered at all, so "the qualifier and nothing else" was never reachable once the source is a linted module. The comparison now allows exactly four classes and counts each on its own: a reference that gained the qualifier, a declaration that moved onto the scope object, a `var` that only changed keyword, a body that gained braces, and a catch clause that lost its binding. Separate counters rather than a total, because the flip commit pins each number and a total would let one class absorb another — which is the drift the pin exists to catch. The two `var` classes partition the 446, and the qualifier's 641 sites partition into references that kept their declaration and declarations that moved. Two ordering facts the cases pin. The catch rule is tried before the brace rule, or the inserted-brace rule eats the `{` that follows `catch` and the streams never resynchronise. A body braced at the very end leaves its closing brace after the baseline has run out, so trailing closes are absorbed after the walk rather than reported as a length difference. Everything outside the four classes still refuses with the token index and both sides: a changed literal, a dropped operator, a reordered pair, a renamed local, a qualifier under another object's name, a brace opened and never closed, and a brace closed where none was opened. Red-first, by mutation: disabling the catch rule, disabling the trailing-brace absorption, folding scope-field declarations into plain references, and not counting brace insertions each fail exactly the case that covers them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the mouse-report cell a module the page can import The first of the twelve groups the document already names. `*-injected.ts` has been splicing JS strings into the document for a while, and tests evaluate those strings, so the one-source-two-consumers shape is already there; what is missing is that a string cannot be imported by the web page, typechecked, or linted. This turns one of them into a module and adds the generator that puts it back into the document. The generator is a transform, not a bundle: a bundler orders its output by the dependency graph, and the document's order is part of what the equivalence test holds fixed. Imports are dropped rather than resolved, because inside the document every name is already in scope — that is what the single IIFE means — and `document-externals.ts` declares the names whose groups have not moved yet and emits nothing at all. esbuild prints an ESM module's exports as a trailing block, so that block is dropped whole rather than by its keyword; leaving the keyword behind would put a bare block statement in the document. Both sides of the comparison now go through that same printer before being read. Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as a difference in the program when it is a difference in who typed it, and each would need its own rule. A script that does not parse is reported as a refusal naming its side, not thrown. `let` is contextual outside strict mode, so acorn reports it as a name and not as a keyword; without that the var-to-let rewrite the linter performs would be refused on every reassigned local. The group's counts are pinned exactly: nine references gained the qualifier (`term` seven times, `panX` and `panY` once each), nine locals became `const` or `let`, thirteen one-statement `if` bodies gained braces, no declaration moved onto the scope object and no catch clause lost a binding. The document is untouched, so the byte pin from3006d8dfdfis still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the query-reply gate a module the page can import The second of the twelve groups, and the one that corrects the scope table's membership rule. `terminalDataRepliesEnabled` is written from four places, so the whole-script census counted it among the 57 variables that cannot stay free across modules. All four writes are in this group. Once the script is modules, a variable written only inside the module that declares it is that module's own state, not the document's, and it stays a `let` there. So the scope object holds what crosses a module boundary, and the 57 is an upper bound rather than the answer; the qualifier count the flip commit pins will be lower than the 641 measured over the single scope, and by how much is a function of where the boundaries fall. Two references do cross here and are qualified: the write-queue generation this group compares against, and the observer-disposal list it pushes onto. Counts pinned: two qualified references, one `var` to `let`, two one-statement `if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make reflow a module, and give the generator its own tests The third group, and the defect it found: esbuild wraps a long import list across lines, and the generator was skipping only the first of them, which left the remaining names loose in the emitted script. The document did not parse, and the equivalence check said so by name rather than throwing — which is what that refusal path was added for. Both lists, import and export, are now skipped to their closer instead of by their first line. The generator's own tests cover what the per-group comparisons cannot say on their own: an export is unmarked and indented into the document scope, a one-line import is dropped, a wrapped import is dropped whole, the trailing export block esbuild prints is dropped rather than left as a bare block statement, and types are erased without touching the program. Reflow's counts: eleven qualified references — the terminal ten times and the settled row count once — six locals that became `const`, and the two early returns braced. The row count is written from three groups, so unlike the query-reply flag it is the document's state rather than one module's. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the keyboard-avoidance metrics a module The fourth group, and the first that needed a non-null assertion. `lineHasVisibleContent` reads the terminal's column count with no guard of its own; the guard is in `computeContentBottomRow`, which is its only caller. Adding a guard would change the program, and optional chaining would change what happens when there is no terminal — the document throws there today. TypeScript erases a non-null assertion, so the emitted script is unchanged and the invariant is written down where the reader needs it. Reflow now imports the metrics call from this module rather than declaring it an external, which is the shape every group takes as its neighbours arrive. Counts: fourteen qualified references, nine locals rebound, ten one-statement bodies braced, and the two `catch (e) {}` clauses — the row scan and the alternate-screen probe — unbound. The scope table's rule is stated more precisely with it: a variable is this module's own only when the group both declares and assigns it. While the rest of the document is still strings, one the main slice declares stays shared even if every use is in one group, because emitting a second declaration beside the one the slice still carries would not be the same program. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make WebGL loss recovery a module The fifth group, and the first carrying a top-level statement rather than only declarations: the visibility listener it registers. In the document that runs when the IIFE reaches it; as a module it runs on import, which is the same single registration. The context-loss listener disposes the addon it is registered on, so it cannot run before that addon exists, but the assignment is to a `let` a closure captures and TypeScript will not carry the narrowing across it. A non-null assertion, erased by the compiler, keeps the emitted script identical and puts the invariant where the reader is. Counts: twenty-three qualified references across the terminal, the addon, its retry timer and the theme the host last sent; three locals rebound; twelve one-statement bodies braced; five of the six catch clauses unbound, the sixth keeping its binding because the attach failure reads the error into its diagnostic. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make indirect-pointer scroll a module, and count a fifth class The sixth group found a rule the four classes do not cover, so I measured the whole script rather than meeting them one at a time: linting all 2,757 lines as a module trips `curly` 279 times and `no-unused-vars` 38, both already counted, and then five further rules at 23 sites — `prefer-number-properties` 17, `prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1 and `no-unused-expressions` 1. Seventeen of those 23 are one rewrite: a global numeric function moved onto `Number`. It has the same token shape as the qualifier, so it is counted as its own class rather than folded into anything, and only the four numeric globals are admitted — anything else appearing under `Number` is refused, which a case pins. Every site is already behind a `typeof … === 'number'` check or is parsing a string, so the two forms are the same test. The remaining six sites are each a different shape and too few to be worth matching; they will surface as refusals in whichever group carries them, and I will report each rather than widen this. The scroll accumulator is the first declaration to move onto the scope: it is declared in this group but a touch scroll in another slice resets it, so the `var` becomes an assignment to the shared field and the class that exists for exactly that counts one. Counts: five qualified references, one declaration moved, four locals rebound, eight bodies braced, one `Number` rewrite. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal surface-swap group into a module The seventh named group. `surface` and the uncommitted terminal are read by other slices, so both move onto the scope; the two committed handles and the pending surface are declared and assigned only here and stay module locals. Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2, unbound catches 2, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): substitute build-time constants into the emitted document The document's script text is not all hand-written: parts of it are template literals interpolating real values, starting with the theme background. A module cannot interpolate and still be the same program, so the generator now derives an esbuild `define` from `document-constants.ts` and substitutes after the import lines are dropped, when the names are free again. The page imports the very same bindings, so there is one source either way. The fixture script's TypeScript loader moves beside it rather than being written twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal theme group into a module The eighth named group, and the first parameterised one: its background fallback comes from the mobile theme through `document-constants.ts`. Two sites carry a line-scoped lint disable rather than the rewrite the rule asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every normalisation class the equivalence instrument counts, so taking them would change the program the native document carries, which is the one thing this branch holds fixed. The reason is on the disable line. Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13, unbound catches 0, number properties 9. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal path-tap group into a module The ninth named group, and a pure query: it reads no shared state, so it has no qualifier sites at all. Two things this group forced. The generator now drops lint directive lines before the transform, because a directive inside an expression makes esbuild parenthesise that expression to keep the comment where it was, and those parentheses are tokens the document does not have. And the two regexes keep their `no-useless-escape` escapes behind a line-scoped disable, for the same reason the theme group keeps `Math.pow`. One name the document declares twice in one function stays `var`. Two block-scoped declarations would be two bindings where the document has one, and esbuild renames the inner one to say so. Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20, unbound catches 0, number properties 2. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal tap-dispatch group into a module The tenth named group, and the heaviest reader of shared state: the selection, its elements, its thresholds and both press origins are all declared by the overlay slice, which is still document text, so all of them move onto the scope with their declarations left where they are. Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal mouse-click-drag group into a module The eleventh named group. The escape byte and both SGR mouse modes join the scope from the runtime slice; the gesture itself is declared here and never read outside, so it stays a module local. Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27, unbound catches 1, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal url-tap group into three modules The twelfth and last named group, and the second parameterised one: both candidate patterns and the length bound come through `document-constants.ts`. Three modules rather than one. At 303 lines it was over the file cap, and the document's own order interleaves the OSC 8 lookup with the file-URL parsing, so the split follows that order and the group's text is the three emissions joined. The test does the joining. Note for a later lane: `terminal-webview-url-tap.ts` and `terminal-file-url-tap.ts` already hold TypeScript twins of some of this, written for the React Native side and not identical to what the document carries. Collapsing the two is a behaviour change and does not belong in a branch whose whole claim is that the document did not move. Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25, unbound catches 6, number properties 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the mouse-mode DECSET scan slice into a module The first of the thirteen inline slices. Both control-sequence introducers, the straddling scan tail and all three mode fields are declared by the runtime-state slice, which is still document text, so they move onto the scope with their declarations left where they are. Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9, unbound catches 0, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal message-bridge slice into a module The script and the document end in the same slice, so the slice splits in two at the point where the IIFE closes: the script half becomes a module, the document half stays text. The byte pin proves the join is unchanged. The second catch keeps its binding: it names the error and reports it. Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0, unbound catches 1, number properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the document close its own slice file The previous commit put two exports in one slice file, which the slice-count guard reads as a mismatch: it derives the slice list from the composer's imports and cross-checks it against the composed entries, one per file. Five suites failed to load. Splitting the file rather than the constant is the better shape anyway. The file was called `message-bridge-and-document-close` because it carried two concerns; now each has its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal term-observers slice into modules This slice interpolates the already-extracted keyboard-avoidance group between its own two halves, so its text is three emissions joined in that order and the test does the joining. A sixth normalisation class, measured here rather than assumed: the printer writes `{ name: name }` back as shorthand, and qualifying the value makes the property name unavoidable again, so one baseline token faces four. It is counted on its own like the others, with its own acceptance case in the instrument's test, and every existing group's pin now carries a zero for it. Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12, unbound catches 6, number properties 0, shorthand properties 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the selection-state-and-eviction slice into a module The slice that declares most of the shared selection state: every threshold, every overlay element and the selection itself, twenty-two scope declarations in one place. The eviction counter is declared and assigned only here, so it stays a module local. Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the smooth-scroll and cell-geometry slice Two modules, not one: the slice carries the normal-buffer smooth scroll and then the cell-to-pixel geometry, and the split follows that order so the group's text is the two emissions joined. Four names stop being externals and become real imports. Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal write-queue slice into a module The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`, which belong to other concerns but sit here because emitted-document order pins them here; four names stop being externals as a result. The observer disposal keeps its guard-as-expression form behind a line-scoped disable: the rewrite the rule asks for is outside every counted class. Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10, unbound catches 1, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal fit-scale slice into a module The slice opens with the already-extracted theme group, so its text is two emissions joined. Four more names stop being externals. Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20, unbound catches 0, number properties 9, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the terminal init-and-write slice into a module The slice opens with the already-extracted webgl-recovery group, so its text is two emissions joined. init() resets almost every field the document shares, which makes this the densest qualifier site in the script. The caret options were interpolated from the theme module, so they join `document-constants.ts` as four exports: a substitution is keyed by name, not by property path. One local the document declares and never reads keeps a line-scoped `no-unused-vars` disable. Removing it would be a different program, which is the one thing this branch does not do. Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18, unbound catches 7, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the runtime-state and text-scaling slice The document's declaration block, where almost everything it shares is declared, with the query-reply and surface-swap groups interpolated inside it. Three modules: the two declarations that come before the groups, the text scaling, and the viewport transform with the scroll indicator. Seven more names stop being externals. Two things this slice forced. The scope-declaration rule now counts each declarator of one `var`, because `var panX = 0, panY = 0` becomes two assignments onto the scope. It has its own acceptance case in the instrument's test. The two halves are compared against their own text rather than as one joined program. The declaration the slice opens with is shadowed by a parameter inside one of the interpolated groups, and printing the baseline as one program renames that parameter; qualifying the outer name removes the shadow, so the rename has nothing to correspond to. Splitting the slice on the group constants compares like with like, and those groups have their own tests. Build-time constants are now substituted textually rather than through an esbuild `define`: a `define` whose value is an object or an array is injected as a helper binding instead of being inlined. Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38, rebindings 25, braced bodies 13, unbound catches 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the two test files the last commit left unformatted Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the mouse-report and scroll-routing slice Two modules around the already-extracted mouse-report-cell group: the viewport cell lookup that precedes it, and the mouse input encoding and scroll routing that follow. Eight more names stop being externals, which leaves ten. Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42, unbound catches 3, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the host-message-router slice into modules Two modules after the already-extracted reflow group: the postMessage bridge with the engine error reporting that rides on it, and the router itself. `notify`, `handleMsg` and `reportEngineError` stop being externals, which leaves seven. The catch binding handed to the error reporter keeps a cast: a catch variable is `unknown` under strict mode, and the reporter reads only `message` before falling back to `String()`. The reason is on the line. Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12, unbound catches 2, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the selection-overlay slice into modules Two modules after the already-extracted path-tap and url-tap groups: the selection range with the xterm mirror, and the overlay positioning with the edge scroll. Six more names stop being externals, which leaves one. Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63, unbound catches 9, number properties 6, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the surface-touch-gestures slice into modules The last of the thirteen slices. Two modules after the three already-extracted groups: the selection menu's buttons, and the touch gestures with the pinch and the momentum scroll. `attachSurfaceEventHandlers` was the last external, so `document-externals.ts` is gone: every name the document uses now resolves to a module. The instrument reads both sides strict. A loose script has to defend Annex B's block-scoped function declarations, and the printer does that by hoisting a `var` and renaming the function, so one side carried a rename the other could not. Neither name escapes its block, so the two readings agree on behaviour and only the strict one can be compared. It has its own acceptance case. Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57, unbound catches 2, number properties 2, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): extract the document's opening declarations into a module The document shell carried the IIFE opener and the eight declarations inside it, so it splits the way the message-bridge slice did: the shell keeps the HTML and the opener, a new slice file holds the declarations, and the byte pin proves the join is unchanged. With this every line of the document's script has a module behind it. Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0, unbound catches 0, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the whole document script against the modules Every line of the script now has a module behind it, so the whole thing can be compared at once. This is the review of the move, as one number per class: qualifier 609 references + 73 declarations = 682 sites var rebindings 373, the document's 446 declarators less those 73 curly braces 279, the number measured before any of this started unbound catches 36 of 38; two name their error and report it Number properties 17, also measured up front shorthand properties 4, two SGR flags written twice each unshadowed names 7 A seventh class was needed and is counted like the others: a binding that shadowed a document variable stops being a shadow once that variable moves onto the scope, so the printer stops disambiguating it. It has its own acceptance case. The module order lives in one file that both this test and the generator read, so neither can drift from the other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): keep only the lint directives that do something Seventeen of the disables were inert: `typescript/no-non-null-assertion` is not enabled here, and a directive naming two rules on one line is not parsed at all, so the one rule that did apply was being ignored too. The changed-code quality gate reports an inert directive as a finding. The two that matter are back, one rule per line: the guard-as-expression in the observer disposal, and the local the document declares and never reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): generate the terminal document from its modules The WebView document is no longer a hand-written IIFE pasted into a template string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts` and the 36 modules under `src/terminal/document/` in document order, strips their imports, exports and line-scoped lint directives, substitutes the `document-constants.ts` exports textually, reprints each with esbuild and wraps the result in one IIFE. `terminal-webview-html.ts` composes the shell, that generated script and the close fragment. The artifact is gitignored and built by postinstall, like the two engine artifacts. The emitted document is token-equivalent to the old one under eight counted normalisation classes, each pinned as an exact number in `document/terminal-document-flip.test.ts` against the pre-flip text: qualifiedReferences 609 scopeFieldDeclarations 73 rebindings 373 bracedBodies 279 unboundCatches 36 numberProperties 17 shorthandProperties 4 unshadowedNames 7 Any other difference fails with the token index and both sides. The second case pins that the new document adds the scope object and nothing else. Ruling 17: the behavioural tests now grep the generated document through `XTERM_HTML`, never a module source, so every assertion still speaks about what the WebView runs. Every assertion stays and the `expect` count per file is unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22, keyboard-avoidance 18, query-reply 14. One control per file was run by deleting the module line the updated pattern guards; all seven red, and the tree restores green. Pattern changes, old -> new. terminal-webview-scroll-routing.test.ts var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y; smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY; var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); -> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH); 'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break }, { capture: true, passive: false }); -> { capture: true, passive: false } function momentumStep() -> let momentumStep = function() pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY; if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) { normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame( pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0; cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId); var writeQueueHead = 0; -> scope.writeQueueHead = 0; writeQueueHead++; -> scope.writeQueueHead++; writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead); surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale(" var FRICTION = 0.972; -> const FRICTION = 0.972; var MIN_VEL = 0.012; -> const MIN_VEL = 0.012; edgeScrollDir = dir; -> scope.edgeScrollDir = dir; term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir); // Latching document-level touch dispatcher -> function attachSurfaceEventHandlers( edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX; edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY; return mode !== 'none'; -> return mode !== "none"; var pixelX = cell.x; -> const pixelX = cell.x; var pixelY = cell.y; -> const pixelY = cell.y; ...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) { ...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) { if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress; if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress; if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press; if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return ""; document.addEventListener('touchend' -> document.addEventListener( "touchend" }, { capture: true, passive: true }); -> { capture: true, passive: true } notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...) document.addEventListener('touchstart' -> document.addEventListener( "touchstart" var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl }); notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput }); terminal-webview-text-zoom.test.ts var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT = var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT \n\n function enqueueWrite -> \n function enqueueWrite var terminalFontFamily = -> scope.terminalFontFamily = output = terminalFontFamily; -> output = scope.terminalFontFamily; String.fromCharCode(0x23fa) -> String.fromCharCode(9210) TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038) EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039) data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR ) writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data)) var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData) } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2) term.open(surface) -> scope.term.open(scope.surface) term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11" enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData) fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily fontWeight: '300' -> fontWeight: "300" fontWeightBold: '500' -> fontWeightBold: "500" terminal-webview-engine.test.ts var webglAddon = null; .. var webglRecoveryTimer = null; -> the refreshTerminalSurface()..init( block, with the scope preamble window.addEventListener('resize' -> window.addEventListener("resize" 'terminal init failed' -> "terminal init failed" 'terminal message failed' -> "terminal message failed" var everReady = false; -> scope.everReady = false; everReady = true; -> scope.everReady = true; fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady /fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/ if (msg.type === 'ping') -> if (msg.type === "ping") notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id }) terminal-webview-reflow.test.ts } else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2) var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20; if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) { flog('measure-skip-small-width' -> flog("measure-skip-small-width" notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... }) var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle" window.addEventListener('message' -> window.addEventListener("message" terminal-keyboard-avoidance-webview.test.ts \n // reflow() -> \n function reflow( } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") \n var panX -> \n scope.panX TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document terminal-webview-query-reply.test.ts attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2) term.attachCustomKeyEventHandler(function() { return false; }) -> term.attachCustomKeyEventHandler(function() { \n return false; \n }); term.textarea.readOnly = true -> term.textarea.readOnly = true; } else if (msg.type === 'clear') { -> } else if (msg.type === "clear") { } else if (msg.type === 'measure') -> } else if (msg.type === "measure") terminal-webview-url-tap.test.ts notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl }); terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the generated document's digest, 730472 -> 723480 bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): delete the slice constants and injected fragments The document is generated from its modules now, so the strings it used to be pasted together from are dead. Deleted: the fourteen slice constants under `terminal-webview-html/` (host-message-router, message-bridge, mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants, runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction, smooth-scroll-and-cell-geometry, surface-touch-gestures, term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write, write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`, `document-close.ts` and `theme.ts` stay: the shell and close are still the document's HTML, and `theme.ts` is where `document-constants.ts` reads the palette from. Ruling 17, second commit. Tests that asserted the extraction mechanism itself went with it: they compared one module's emission against the slice text it was extracted from, and the flip test now pins the whole document against the whole pre-flip script with the same eight classes. Deleted, all under `document/`: fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge, mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing, mouse-report-cell, path-tap, query-reply, reflow, runtime-constants, runtime-state, selection-overlay, selection-state-and-eviction, smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures, tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery, wheel-scroll. `document/url-tap.test.ts` stays: it pins against `URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and still has a consumer. Tests that asserted behaviour through a deleted string now read the generated document. `document/generated-document-region.test-support.ts` is the one way in: `documentScopePreamble()` returns the scope object the document opens with, and `generatedDocumentModule(name)` re-emits a module and refuses unless the document carries that text verbatim, so an evaluated block is the WebView's own bytes. The two local copies of the preamble in the engine and text-zoom tests were folded into it. Moved, with every assertion kept and the `expect` count per file unchanged: terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34 terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14 terminal-webview-query-reply.test.ts 14 terminal-path-tap.test.ts 25 terminal-webview-url-tap.test.ts 33 terminal-keyboard-avoidance-webview.test.ts 18 terminal-webview-reflow.test.ts 22 terminal-webview-text-zoom.test.ts 59 terminal-webview-engine.test.ts 49 Pattern changes, old -> new. terminal-webview-reflow.test.ts if (!term || isAlternateBufferActive()) return; -> if (!scope.term || isAlternateBufferActive()) { term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows); var wasAtBottom = buffer.viewportY >= buffer.baseY; -> const wasAtBottom = buffer.viewportY >= buffer.baseY; term.scrollToBottom(); -> scope.term.scrollToBottom(); if (nextCols === term.cols && nextRows === term.rows) return; -> if (nextCols === scope.term.cols && nextRows === scope.term.rows) { The other eight files kept their patterns; only the text they read changed, from a deleted constant to the document block. The harnesses that evaluate a block now build the document's scope object instead of declaring the vars it replaced, and hand the terminal in as `scope.term`. Controls, one per file: the module line an updated pattern guards was removed, the document rebuilt, and the test run. All red, and the tree restores green. query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed path-tap const parsed = parsePathLineCol(...) -> path-tap test, red keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed terminal-theme scope.term.options.minimumContrastRatio = ... -> theme test, 4 failed write-queue scope.writeQueue[scope.writeQueueHead] = undefined -> write-queue test, 4 failed `document-scope.ts` docstrings named the slice each field belonged to; they name the owning module now. Three module comments pointed at deleted injected files and point at the modules instead. Neither changes the document: esbuild drops comments, and the byte pin is unmoved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the right number of counted classes The flip test's title still said seven; the table it asserts has eight. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the shape applyTerminalTheme writes through The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test. `applyTerminalTheme` touches exactly two slots on the terminal it is handed, so `terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and the test's parameter and both fixtures use it. The theme is optional on the way in because `applyTerminalTheme` is what writes it. No cast. The type is erased by the generator's transform, so the document is unchanged and the flip test's class table and the byte pin both still hold. Control: restoring the `object` parameter reproduces the finding at terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type it exits 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): retire the flip pin, leaving the byte golden as the document's fence `terminal-document-flip.test.ts` compared the emitted modules against `terminal-document-pre-flip-script.txt`, the hand-written script as it stood before C7.1, and held exactly while no module changed. That is the proof of the flip, not a standing fence: the first lane that must change a module has to retire it or restate its counted classes for a reason that has nothing to do with the move. C7.5 is that lane — the document's host seams become scope fields so the page can set them — so both go here, while the test is still green. The flip proof lives at51ae7b1b03("test(mobile): name the right number of counted classes"), which is where anyone reviewing the move should read it. From here the standing pin is the whole-document byte golden, `terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by the payload-hash digest beside it. Regenerating it is a review event: the emitted diff is listed old to new in the commit message and in the PR body, and a golden that moves without a listed diff is a blocking finding. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the terminal document's host seams a field on its scope Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a terminal `notify` through it would post raw terminal JSON into the bridge's channel, and there is no engine IIFE hanging `Terminal` and the two addons off `window` because the page imports xterm. Four reads had to become seams: host-notify.ts notify() -> scope.postToHost viewport-transform flog() -> scope.postToHost terminal-init.ts new Terminal(...) -> scope.createTerminal terminal-init.ts window.Unicode11Addon-> scope.createUnicode11Addon webgl-recovery.ts window.WebglAddon -> scope.createWebglAddon Each default is the window read the site already did, still performed at call time and not captured when the scope is built, so inside the WebView the program is the one it was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object, because the scope's defaults are those functions and the factory runs as the script is parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's file, which the four fields pushed over the 300-line cap; document-scope re-exports them, so no importer moves. The page's side of the seam lands in C7.5's later commits. Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside the `try` it was built in, so a constructor that throws is still swallowed; and no WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`, which is the DOM-renderer fallback rather than a failure. Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document 723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else: + (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } } + (new) function createEngineTerminal(options) { return new Terminal(options); } + (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; } + (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; } - " pendingTerm: null" + " pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon - flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }" + flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });" - " if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {" + " if (!scope.term) {" - " addon = new window.WebglAddon.WebglAddon();" + " addon = scope.createWebglAddon();" then " if (!addon) {" / " return false;" / " }" - " scope.term = new Terminal({" + " scope.term = scope.createTerminal({" - " if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / " try {" / " scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / " } catch {" + " try {" / " const unicodeAddon = scope.createUnicode11Addon();" / " if (unicodeAddon) {" / " scope.term.loadAddon(unicodeAddon);" / " } catch {" - notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }" + " scope.postToHost(msg);" Nothing else in the document moved: the emitted indentation, statement order and every other literal are byte for byte what they were. Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions and aimed them where the text now is: `window.WebglAddon.WebglAddon` and `new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on the recovery module, and the recovery module is asserted to call `scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope before the globals exist to show the defaults read the window when they post, shows each addon factory answering null when the engine has none, and drives a host message in and a notify out with all four fields set, asserting the bridge is never touched. Red before this commit at 6 of 7 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): write the xterm stylesheet as its own generated artifact The page mounts xterm itself, so it needs the engine's stylesheet and must never resolve the engine string: 612 KiB of minified IIFE built to be injected as text into a WebView document, unusable under the shell's `script-src 'self'` with no nested frame to load one into, and the largest single module the session route's closure would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of the CSS pulled the string in behind it. `build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts` beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside its neighbour and written by the same postinstall step, so a fresh tree gets both or neither. `document-shell.ts` takes the CSS from the new module and the engine string from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold both constants read them from their new homes. The document did not move: `terminal-document-golden.txt` is byte for byte what the last commit left, 105,968 bytes, and the payload digest is unchanged. The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks every module under `src/terminal/document/` as an entry point — the document is one script whose modules reach each other by side effect, so no single one of them roots a graph holding the rest — and asserts the engine string is in none of their closures, with two modules named as the precondition that the walk resolved anything at all. The native document's own closure is asserted to still hold both generated modules, so the first case cannot pass by the CSS having gone missing. And the third case plants a document module that imports the engine string in a scratch tree and shows the walk reports it, which is what makes the absence above a measurement. `mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes the entry points and an optional working directory; the route closure's own two entry points and its extensionless-specifier reason are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the dead URL-tap constant and two stale reflow guards Round 1 fixes, all three folded here. 1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with `document/url-tap.test.ts` deleted alongside it. The document is generated from its modules now, so that constant was a second copy of the URL-tap group with no consumer but its own tests. terminal-webview-url-tap.test.ts's resolver harness reads the document's own text instead, the path-tap, url-tap, osc-link-tap and surface-tap modules in document order through `generatedDocumentModule`, which refuses unless the document carries each verbatim. Its 33 expects all stay. One mechanism-only assertion went with the file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts` pin of the three emissions against the constant, which the flip test's whole-document pin already covers. The file's other exports stay. The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts concatenated terminal-webview-url-tap.ts into its `source`, and its `notify({ type: 'terminal-tap' });` assertion was matching the constant's single-quoted text, not the document. The read is dropped, since nothing else in that file needed it, and the assertion is the document's form: notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" }); Its 95 expects stay. Leaving the read in place would let a document assertion pass against a module source, which is the hazard this lane exists to remove. 2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer exists, so it could not fail: expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}') -> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1) Same intent against the generated document: the reflow module's emitted text is in the document exactly once. The case is renamed to say so and the comment above it describes the generator, not the deleted template. 3. Same file, the routine assertion still passed as a substring of the qualified call; qualified as line 30 already was: term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows); Its 22 expects stay. Controls, each verified to have changed the file first, all red, tree green after restore: osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed module order 'reflow' listed twice -> reflow test, expected 2 to be 1 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): mount the terminal document in the page instead of a WebView `react-native-webview` has no web build that renders anything: measured, it paints the line "React Native WebView does not support this platform" where the terminal was. So the page mounts the document itself — xterm imported from `@xterm/xterm` with the unicode11 and webgl addons, and the document's own modules imported in the order the generator emits them — behind the identical `TerminalWebViewProps` and `TerminalWebViewHandle`. Written as one implementation, not two. `use-terminal-webview-controller.ts` is everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the readiness handshake, the pending queue, the write coalescer, the notify dispatch and the whole imperative handle. Its two arguments are the difference between the hosts — a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to re-prove the document with a ping. The native component posts across the bridge and answers yes on iOS; the web component calls `handleMsg` and answers no, because its document is the page's own modules and there is no second content process to lose. A second copy of that file is the fork the series exists to avoid, since the handle is the contract every consumer holds. `terminal-webview-ready-promises.ts` carries the two promises the handle hands out, `awaitReady` and `measureFitDimensions`, which the controller's length made a module. `document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out of the document shell; the shell composes them and the golden is byte for byte unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the caret options and nothing else, so the page resolves no document string and no engine string. `terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the stylesheet and the markup, sets the four scope seams, and reaches the modules by one dynamic import — they read their elements as they are parsed, so a static import would hoist above the planting and leave every one of them holding null. `page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19 because on the page those `message` frames belong to the shell; its one non-bridge duty, the window-resize refit, is re-armed by the mount. `page-document-module-order.test.ts` holds that list against the generator's own, so a sorted import list or a module added on one side cannot pass. Two page-side degradations, both bounded and both stated. The document assigns `window.onerror` as it is parsed, so while a terminal is mounted page errors reach its reporter; the mount restores the previous handler on dispose. And a browser that refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery` already has for a context loss, with a `[fit]webgl-unavailable` notify saying so rather than a silent halving of the drain rate. `terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it scans `src/session` and the terminal directory for an import of the component file by name, of `terminal-webview-html`, of either generated engine module or of anything under `document/`, finds none outside the component and its mount, and shows on planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs` gains the component's own closure: `TerminalWebView.web.tsx` and `terminal-webview-html.web.ts` are in it, the engine string, the native HTML module and `message-bridge` are not. Four source greps follow the code into its new home, every assertion kept: `terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the controller, and reads the two lifecycle clears once in `resetReadiness` plus both WebView callers in the component; `terminal-webview-reflow` and `terminal-webview-scroll-routing` read the handle in the controller and the two timers in the promises module (`measureResolveRef.current === finish` -> `measureResolve === finish`, `void p.finally` -> `void pending.finally`). One behaviour was nearly lost and is pinned by an existing case: the native foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render, so the transport asks a predicate rather than carrying a boolean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): render the page's terminal in a browser under the shell's policy Everything below the contract is new on the page: xterm is an import rather than a 612 KiB string in a WebView document, the document's modules run in the page's own realm, and the elements they read by id are planted by the component. No module test settles whether that opens at all under `script-src 'self'` with neither `unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the buffer intact. Three cases in the C6 render harness, against the bundle built by the real builder and served under the policy parsed out of the shell's own Kotlin constant. The stream is built for the grid rather than committed: an SGR colour change per cell, an erase-to-end and an absolute cursor position per row, run out past the host's own 48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the document's own path — select all, then the Copy button the overlay carries — so the oracle is the component's `onSelectionCopy` prop and not a private reach into xterm: 6,133 characters, both edge markers present, and no escape byte or SGR text left in them, which is what says the parser consumed the stream instead of printing it. The second case takes a fit through the handle, which on the page is a command in and a notify back with no bridge between, and carries design §8's cheap half of the IME question. It first pins something that changes where that probe can even point: xterm's own textarea is inert by the document's design — `query-reply.ts` makes it read-only, untabbable and `inputmode=none` so touch and hardware keys go to the screen's input — so text entering a terminal on the page arrives at a `TextInput`, and that is what is typed into. Chrome reports `insertText` with `isComposing` false for each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft keyboard is the device step and this does not claim to answer it. CSP violations are counted with a `securitypolicyviolation` listener installed before anything else runs, which is stricter than the console-error filter the other render checks use — and the first thing it found was not the terminal's. The page entry carries Zod, whose `new Function` probe is swallowed by its own catch, so `script-src: eval` is refused once on any page route with no page error and no console line. The first case is the control that names it, on a route that mounts a marker and no terminal; the two terminal cases subtract it and report zero of their own. Zero page errors and zero console errors besides. No route serves this screen until C7.7, so the component is bundled through a scratch route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx` exactly as a real route would. That step retires when the session route is registered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): retire the last module concatenator and guard the order list Round 2 fixes, all five folded here. 1. Deleted terminal-webview-html-source.test-support.ts. `readTerminalWebViewHtmlSource()` had no consumers left once the behavioural tests moved to the generated document, and it was the last thing that built a document-shaped string by concatenating module sources — its filter admitted `.test-support.ts` files too, so it could have grown one. Confirmed by grep that the only occurrence of either name in the repository was its own declaration. 2. New document-module-order.test.ts asserts both directions: the non-test, non-test-support `.ts` files under `document/` are exactly `{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`, and no name is listed twice. `document-constants` is the one exception because it is never emitted: its exports are substituted into the modules that import them as literals, so the document carries its values without carrying the module. A module added here and forgotten there would be dead code that reads as live; a name left after its file goes makes the generator throw at build time rather than at review time. 3. terminal-document-flip.test.ts's docstring now carries the retirement policy from ruling 18: the test is the proof of the flip and holds only while no module changes, the first lane that must change one retires it together with `terminal-document-pre-flip-script.txt`, and the standing pin from then on is `terminal-document-identity.test.ts`, whose fixture regeneration is a review event. Comment only. 4. terminal-document-equivalence.test-support.ts said 57 reassigned variables and "Four classes and no others". It now says 73 declaration sites and eight classes, with each class's measured figure named. Two doc comments sat above the wrong declaration and were moved onto what they describe: the `NUMBER_GLOBALS` one down to that constant, and the printing one down to `significantTokens`, with `STRICT_DIRECTIVE` given its own line. 5. build-terminal-document-script.mjs substituted constants with `replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a constant's value are read as replacement patterns. The substitution is now `substituteDocumentConstants`, exported so it can be tested directly, and replaces with a function. Controls, each verified to have changed its input first, all red, tree green after restore: plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module" drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll" revert to the string replacer -> 4 failed, "a $& b" became "a marker b" The `$n` case is deliberately absent from that table: the pattern has no capture group, so `$1` is already literal under either form and a case for it could not tell them apart. The document did not move. The byte golden, the digest and the flip test's class table are all unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): measure what the page terminal costs the session route's closure The session route is not served on the page until C7.7, but the closure the bundler would walk is the same one and the terminal is the largest thing in it. Measured against this branch's base, `ota-c7-1-terminal-document` at51ae7b1b03: modules 4316 -> 4363 (+47) local modules 927 -> 971 (+44) minified bytes 3,930,787 -> 3,883,532 (-47,255) The route gets smaller. It sheds six modules — the native component, the 612 KiB engine string, the 105 KiB generated document script, the HTML module and the shell and close around it — all string literals of a program the page cannot run, and gains fifty: the component, its mount, the stylesheet and markup modules, the two the controller split made, and the document's own thirty-nine, with xterm and the two addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts` is not among them: it declares types and esbuild emits nothing for it. The census pins the trade in both directions, because "the engine string is absent" passes just as well on a closure that resolved nothing: the six shed modules are asserted gone, the eight gained ones and the three xterm packages asserted present, and the document asserted whole except `message-bridge`, which ruling 19 keeps off the page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved styles — since the terminal's modules joining this closure is exactly the change that could add a tenth unread. The page-closure families were run before and after on the full corpus, never a filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879 include the four page-closure pins, which assert the verdict of every golden C1, C2, C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed and 773 skipped. No family moved, which is what an inert lane should show: this branch changes no RPC, no opcode, no grant and nothing the recorder reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): clear the changed-code gate findings this lane introduced Eleven findings from `check-changed-code-quality.mjs` against the base, all in code this lane added, none of them a behaviour change. Two type assertions lost their directive to the formatter. The xterm `Terminal` cast sits on the second line of a wrapped arrow body, so a directive above the assignment aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon cast had no directive at all. Both keep the same `SAFETY:` rationale on one line, which is the only shape oxlint reads. Two more assertions in `host-seams.test.ts` are gone rather than annotated. The terminal double's `element` is a getter over a local the double's own `open` writes, and `withSeams` reads each field it is about to overwrite through `getOwnPropertyDescriptor` instead of indexing the scope with a cast. Then three `eslint-disable no-console` directives that disabled nothing, an `oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the reason it carried stays as a comment, since the dependency list is still deliberate — and one duplicated `node:fs/promises` import. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(config): name the closure helper what main already named it A trial merge against `origin/main` conflicts on this function: main grew the same generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with `mobileWebAppRouteClosure` delegating to it and three callers in the page-closure families census. This branch is based on `ota-c7-1-terminal-document` and so cannot merge main, but it can stop being a second spelling of the same thing. Taken over wholesale: main's name, its parameter, its extension stripping and its comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main already has. The only addition is an options bag carrying `absWorkingDir`, which the engine-closure census needs to plant a module in a tree of its own and show the walk would report it; the real measurements never pass it. What was a whole-function conflict is now that one hunk. The census case that measured the native document had named `terminal-webview-html.ts` with its extension, which main's stripping does not allow. It names `terminal-webview-html/document-shell` instead — the module that actually reads both generated ones — which is the better probe anyway and needs no extension to resolve, since it has no `.web` sibling. `web-overrides.json` also conflicts and is left alone: both sides append entries to one list and the resolution is mechanical. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(config): put the two closure helpers in main's order The previous commit took main's name and signature but left the route closure below the module closure, where this branch had written it. Git merged both orderings and produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint reports as a duplicated export — a red the trial merge found and neither side's own lint could. Same order as main now: the route closure and its docstring first, the module closure under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the merged tree lints clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the flip comparator refuse what it was accepting Round 2 items 6 and 7, both in the equivalence instrument. 6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving the two were the same binding, so an unrelated rename ending in a digit would have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`, an explicit list of pre-flip name, generated name and declaring module. The whole script has one entry: `term2` -> `term` in `query-reply`, which is the `term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven sites in all. That is stated in the docstring rather than encoded as a second pin, since the flip test already pins the total. 7. Brace absorption treated every unexpected `{` as a linter-added body and absorbed any later `}` while one was outstanding, so a bare block anywhere would have been swallowed. `isBraceableHeadBody` now requires the open to be the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its `(` and reading the keyword before it — and `matchingCloseIndex` records the index the close must appear at, so the absorbed `}` is that body's own. That check had to move ahead of the equality check. Wherever a braced body ends a block, the baseline's next token is a `}` as well, so pairing them would consume the wrong one and leave the counts right for the wrong reason. Both refusals are tested over snippets: function f() { return value2; } vs return value; -> token 6: expected name value2, generated name value let value = 1; use(value); vs { let value = 1; } use(value); -> token 0: expected name let, generated { and the braceable heads are tested one by one, `if`, `for`, `while`, `if`/`else` and `do`, so the new rule is shown to accept every shape the `curly` rule produces and not only the one the document happens to exercise. Controls: restoring the shape rule fails the first refusal case and nothing else; restoring the accept-any-brace rule fails the second and nothing else. The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7. Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The tightened rules put the file over the 300-line cap, and a `max-lines` disable is forbidden, so the token reader moved to its own module: that side answers what a script says, and says nothing about which differences between two of them are allowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(config): take main's docstrings for the two closure helpers The order matched but the prose did not, so the trial merge still conflicted on the whole block. Both docstrings are now main's own text, with one sentence trimmed: main names `MobileBrowserPane` as the first component with a pin of its own, which is C6's fact and not one this branch can assert. What remains between this branch and main in this file is the `absWorkingDir` parameter, which is what the engine-closure census plants a module with. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page terminal's notify sink in an effect, not during render React Doctor's one error on this branch, and a real one: `receiveRef.current = receive` ran during render. React may replay or discard render work, so a mutation made there can leak from UI that never commits — and this ref is read from a callback the mounted document keeps, which outlives the render that installed it. Moved into its own effect, declared above the mount effect so the first read already sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0. Found late because the first run of that gate was read through `| tail`, which reports the pipeline's last command rather than the gate's own exit code. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): teach C7.1's order guard the three modules this lane added The guard C7.1 landed says the document directory and the order list name the same modules. On this branch three files are in that directory and not in that list, so it was red on the merge — which is the guard working, and the fix is to name each of them with its reason rather than to loosen the scan. document-host-seams emitted, but ahead of the scope rather than inside the order list, because the scope's defaults are its four functions and the factory runs as the script is parsed document-terminal-shape types only; esbuild emits nothing and an empty emission would add a blank line to the document page-document-modules the page's entry, not the WebView's, holding the same order for a host that has no generator to splice them Named one by one, not filtered by a pattern, so a fourth cannot join them by looking similar. A third case asserts the seams module is neither in the order list nor the scope module, which is the ordering the first two cannot see. Red before this commit: C7.1's version of the file on this tree reports `document-host-seams` and the other two as directory modules the list does not name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): re-measure the session closure against the merged C7.1 base Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`, a module that enters this closure only once the page's component reaches it, so the saving shows on the after side and cannot show on the base. Both readings are recorded with the commit each was taken against, because a number with one base named and another used is the kind of thing a reviewer cannot check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): retire the flip comparator with the pin it was built for The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts` went in C7.1's own round-1 fold at8da7680c9b, and `terminal-document-flip.test.ts` went in this lane's first commit under ruling 18, because the flip pin holds only while no module changes and C7.5 is the lane that changes them. What was left was a tool, its token reader and a test of the tool, answering to nothing. So `terminal-document-equivalence.test-support.ts`, the `terminal-document-tokens.test-support.ts` C7.1 split out of it, and `terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on the comparator — bounding an absorbed body to one statement, and refusing a bare block as `use();` against `{ use(); }` — since there is no comparator left to tighten. The standing pin on the document is the whole-document byte golden, which is a stronger claim than token equivalence ever was: it admits no normalisation at all. `document-module-order.test.ts` gains the case its exception list was asserting in prose. `document-terminal-shape` is not in the order list because esbuild erases a module of type declarations to the empty string, and emitting it would put a blank line in the document rather than a program; that emission is now measured and pinned as `''`. If the module ever declares a value the case goes red and the module belongs in the order list with its own line in the golden diff. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): make the document's error reporter the sixth host seam Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which inside the WebView is taking nothing from anyone — that document owns its page — and on the page is a guest displacing whatever the host installed. Restoring it on dispose was a patch over the takeover, not an answer to it: while a terminal was mounted, every page error still went to the terminal's reporter. So `scope.installErrorReporter` joins the five, with today's assignment as its default. `host-notify` hands it the same handler it always installed, and the WebView's document is the program it was. The page supplies its own: an `error` listener that adapts the event to the reporter's arguments, added on mount and removed on dispose, and `window.onerror` is never written. This one seam is *called* as the modules are parsed rather than later, so the mount now reaches `document-scope` on its own first and sets every field before a single document module runs — which is also the safer order for the other five. Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines out, seven in, and nowhere else: + (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; } - " createWebglAddon: createEngineWebglAddon" + " createWebglAddon: createEngineWebglAddon," and " installErrorReporter: installWindowErrorReporter" - " window.onerror = function(msg, source, line, column, err) {" + " scope.installErrorReporter(function(msg, source, line, column, err) {" - " };" + " });" `terminal-webview-payload-hash.test.ts` takes the new length and digest. Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror` and a host that installs its reporter elsewhere leaving it null. The render check adds a browser case: `window.onerror` is null before the mount, null after it, and null after the component unmounts — with a real uncaught error thrown in between and asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that had simply stopped reporting, and a second error after dispose asserted to reach nothing. Red with the mount's override removed: `expected undefined to be null`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): empty the session closure's react-native-webview list C7.6's census on main names the terminal as the last consumer and says whose work it is: "The terminal is the third and is C7.5's, which drops the engine string and mounts xterm in the document". This is that lane, so the list it left is now empty and the session closure reaches `react-native-webview` from nothing at all. Emptying a list weakens the case that reads it, because an empty result is also what a scan that read no file reports, so two things change with it. The main case gains its preconditions: the walk read a closure of more than 500 local modules, and it read the three web siblings whose native halves are exactly the modules that would have imported the package. And the control stops walking the list — with the list empty that compared nothing against nothing — and walks the three native files instead, which do import it, alongside the three web siblings, which do not. `TerminalWebView.web.tsx` joins the answered list, so the case that the builder resolves a web sibling rather than its native file now covers all three. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): pin the onerror seam against a handler the page actually owns The case read `null` before the mount, while mounted and after dispose. That is true but weak: a terminal that assigned `null` over a real handler would pass it, which is exactly the takeover ruling 19 forbids. So the page now installs a handler of its own in an init script, before the bundle loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`, compared inside the page because a function does not survive `evaluate` — at all three points. Between them an uncaught error is thrown and both reporters are asserted to see it: the page keeps the handler it installed, and the terminal's own listener still works, so the readings cannot pass on a terminal that had simply stopped reporting. After dispose a second error reaches the page's handler and not the terminal's, which is what taking the listener off has to mean. The `null` reading stays as its own case, because the other half matters too: on a page that installed nothing the terminal must not leave a handler behind for the next consumer to find. Both go red with the mount's `installErrorReporter` override removed — `expected false to be true` and `expected undefined to be null`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start the terminal document per mount (ruling 20) Round 1's blocking finding: ES module bodies run once per page, so the page's second mount re-imported nothing and inherited the first mount's elements, listeners and error reporter. Measured after a remount: zero .xterm nodes in the live DOM, no selection overlay, nothing reaching onEngineError, and onWebReady still firing. Ruling 20: no emitted module does work as it is parsed. Every top-level effect moved into an exported per-module start function — 86 statements across 14 modules, plus three parse-time captures whose declarations became typed lets. The generator emits one call sequence in module order at the foot of the document, so the native script still runs them once at parse; the page runs the same sequence per mount and dispose undoes the three that outlive the host element (tap-dispatch, webgl-recovery, host-notify). installErrorReporter now hands back its own undo, so it stays five seams at six document sites rather than growing a sixth. M2: a failed document chunk was an unhandled rejection with no engine error. It now goes down the document's own reporting path, so the overlay names the cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at the wire in the render check. L3: the seam count now reads five fields / six sites / three files everywhere. L4: three unrelated web-overrides entries keep main's escaping. Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256 2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the parse-time census tree without assertions The changed-code gate refuses type assertions. The walker reached node fields through `as Record<string, unknown>`; it now reads them with Object.entries, which is checked and says the same thing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the document's state onto the scope (ruling 21) Round 2's blocking finding, and ruling 20's second half: moving parse-time effects out of the module bodies left the state behind. Nine module-level bindings survived a mount, so the second terminal inherited a spent non-fatal error budget (reporting nothing however it failed), the first terminal as its committed surface (disposing it twice), and the first mount's momentum loop. Every mutable binding now lives on the scope, and the scope carries one reset the start sequence calls first: native once at parse, the page once per mount. Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1, host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1, tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the objects tap-dispatch and surface-touch-gestures used to own outright. Because the reset is now the one initialiser, the start functions keep only what it cannot do: element reads, listener installs and the reporter install. Four start functions emptied and went; terminal-handle held nothing else and is deleted from the order list. The scope type splits into state and host seams, because a reset must restore the first and never the second. Every stop function cancels what its module scheduled. Timers go back through the handles the scope already held; frames go through the scope's own scheduleDocumentFrame, so dispose can take back the ones no module tracks by id. terminalGeneration and fitRetryToken carry forward across a reset, because a stale callback tests itself against them and a reset to zero would make the old number match again. L2: the seams-before-scope case asserts the order in the emitted document, not just non-membership. L3: the style docstring says what is true — one scope per page, so mount refuses a second live document and gives the page back when a mount fails. Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256 6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse frames from a stopped document The frame case went red under full-suite load: tearing the terminal down runs the engine's own disposal, which calls back into these modules, and a frame asked for on the way out was owed by nobody because the cancel had already run. A stopped document now asks for no frames at all, so the ordering inside dispose stops mattering. The render case is also rewritten around the work that survives a loaded machine. It gives the terminal a scrollback and sends one wheel, which reveals the scroll indicator and arms the 550 ms timer to hide it again, and the boundary between the two mounts is drawn when the first terminal leaves the page rather than when the component is told to go — React unmounts on its own schedule, and a callback that runs while the first terminal is still up is not a leak. The precondition counts what the document scheduled under the first mount, so an empty leak list cannot mean the wheel reached nothing. Verified both ways at this head: red with stopViewportTransform and cancelDocumentFrames removed, green with them, and green in the whole config/scripts suite. Payload 726081 -> 726195, sha256 67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): style only what the page mount owns CodeRabbit on document-style.ts:16. The mount appended the document's whole stylesheet to the page head, so its `*`, `html` and `body` rules restyled every screen the shell can show and went on doing it after unmount. Ruling 19's shape: the native document owns its page and keeps the sheet as it is; the page mount may style only what it owns. The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted document does not move for the split - verified byte-identical before the seam below. The page injects the element half only, with every selector held under the host's own class, and xterm's sheet goes through the same rewrite. The rewrite refuses an at-rule rather than passing its inner selectors through unscoped. A second leak of the same kind was in the same measurement: applyTerminalTheme wrote the terminal background straight onto `html` and `body`. That is a sixth seam - six fields at seven document sites now. Its default does exactly the two writes it did; the page paints the host element instead. Emitted lines, old to new: `paintWindowDocumentBackground` added beside the other defaults (3 lines); `paintDocumentBackground: paintWindowDocumentBackground` added to the seam factory (1 line); in applyTerminalTheme, the two `document...style.background` writes become one `scope.paintDocumentBackground(background)`. Leaving the sheet in the head after unmount is kept, and is now defensible: the host drops the class on dispose, so every rule in it matches nothing until the next mount. The render check gains a case comparing `body` and `html` computed styles, while mounted and after dispose, against a page of the same application with no terminal on it, and asserting no rule of the injected sheet matches an element outside the host. Verified red both ways at this head: unscoped sheet moves `background-color` and `box-sizing`, and the inline theme write moves `background-color`. Payload 726195 -> 726363, sha256 9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the page mount's rules and frames their own oracles Round 3 blocks on evidence, not on shipped behaviour. Each item: H1. The scoping had no positive oracle: dropping the host class, or injecting an empty xterm sheet, left the render check green, because every assertion was about rules not escaping. The containment case now also reads four things off the live elements under the host — xterm's own `position: relative`, the viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width, and the overlay's `position: fixed`. Red both ways: no host class reds all four, an empty engine sheet reds the first. H3. `cancelDocumentFrames` had no witness: the only leak the timer case could see was the 550 ms hide timer, which its own module's stop cancels. There is now a case whose witness is a frame taken through `scheduleDocumentFrame` — the fit retry loop, with the surface hidden so the fit never commits and one frame is always owed at dispose — and it reds when only `cancelDocumentFrames` is removed. A unit covers the registry itself: a frame is held until it runs, a cancel takes back every pending one and then refuses to schedule, and a reset re-enables it. The two scheduling cases now assert on their own witness kind, so neither can stand in for the other, and the recorder judges a leak by whether the `#terminal-container` that was on the page at schedule time is still in the document — React unmounts on its own schedule, and a callback that runs while the first terminal is still up is not a leak. The timer witness moved from the scroll-indicator timer to the long-press timer, because the first needed a drained scrollback and raced the engine under load; its precondition caught that rather than passing. L1. The two seam docstrings each sit on their own function. L2. The parse-time census plants an element-read initialiser, which the statement filter cannot see, and an inert object literal, which a reader that flagged every initialiser would wrongly report. L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that never committed leaves two terminals and only one was reached. Deduplicated, because they are the same object whenever no swap is open, and pinned both ways. L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT. Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render check: 12 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the page document's dispose idempotent and owner-checked CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A handle outlives what it built - the component keeps one in a ref and React can run a cleanup after a later mount has started - and everything dispose touches is shared: the scope, the module sequences, window.__engineErrors. So a second call, or a call from a handle whose document had already been replaced, tore down the terminal that was on the screen and handed the page away while it was still in use. Each mount now carries a token, and dispose acts only when that token is still the live one. A token rather than the host element or its class: two mounts can be handed the same element, because the page remounts into a host React has reused, so an element is not an identity and the class says only that some document is using the host. The failed-mount path releases the page under the same check. Pinned both ways, red with the check removed: disposing twice leaves a terminal put back after the first teardown alone, and a stale handle disposed after a second document mounted changes nothing - the live markup stays, its terminal is not disposed, and the page is still refused to a third mount. Golden unchanged at 108,329 bytes; payload and hash unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a pending page mount be disposed before its import lands Round 4 on #21809. H1. The mount claimed the page before its dynamic import and handed back a promise, so a component cleanup that ran while the chunk was still in flight had nothing to dispose: the claim outlived the mount it was made for, and Reload — the recovery ruling 20 names — was refused as a second document. The claim, the markup and the handle are now made synchronously, `ready` settles on its own, and a mount disposed while its import was in flight releases without starting anything. Pinned in the render check by holding the document chunk 20 s past the 15 s readiness watchdog, clicking Reload and waiting for the second mount to become live; red at that wait before the change. M1. The frame case's precondition asserted that a frame had been asked for while the document owned the page, not that one was owed when it was disposed. The fit retry commits on its first attempt whenever the grid still measures, so a dispose between two refits owed nothing and agreed with an empty leak list for exactly the reason under test — one run in five. The refit and the unmount now share one discrete click, which React flushes before the event returns, and a mutation observer reads the registry at the instant the host is emptied. Five red runs without `cancelDocumentFrames`, all on the leak and none on the precondition, and five green with it. M2. Two mounts handed the same element, which is what the token is for: the other six cases use a different element each, so a host comparison passes all of them. L1. A throw inside the start sequence released the token but ran no stop, leaving the host-notify error listener installed until the next reset nulled its undo. The sequence now unwinds the starts that completed, in reverse, before it rethrows. L2. A render case comparing the window and document listeners the page holds with no terminal on it, before and after a mount, so a stop that forgets one is a failure rather than a second copy per terminal ever shown. L4. Separated the stacked docstrings in the parse-time-effects census. The render check's bundle, server, browser and page helpers move to their own fixture module: the cases are what is under review and the scratch route tree is not, and the file was 16 code lines under its cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): count the page document's leaked frames from dispose, not from detach CI's addendum to round 4's M1: the frame case failed with the fix present, `expected [ Array(1) ] to deeply equal []`, on a slower runner. What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every other frame the document asks for — the document has no other rAF call site. It is not an escape from the registry, so the registry is not what changes here. Why it was counted: React unmounts in two steps. The mutation phase detaches the host, and the passive cleanup that calls `dispose` runs after it — about 1 ms later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner shape this failed on. A frame served in that gap runs with a detached container while the document is still live and has not been asked to stop, and nothing could have taken it back: `cancelDocumentFrames` had not been called yet. The oracle judged by the captured container's connectedness, so it read the gap as a leak. It now counts only what runs after the last statement of `dispose`, which is the class coming off the host, observed on the element because React may have detached it already. The same reading fixes the other direction. The precondition is read at that same moment, and the witness is a refit re-armed from a frame of the test's own, so the document is owed a frame at the end of every frame the browser serves and a dispose cannot land where nothing is owed. The single refit the case used before bought one frame, and the retry loop commits on its first attempt whenever the grid still measures. Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two runs of three unthrottled, and in five of five with the CPU throttled 20x, where the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with `cancelDocumentFrames` removed, five red runs, all on the leak read and none on the precondition. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop a page mount that lost its claim before it writes the scope Round 5 on #21809. F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await import(...)` the whole body ran whatever had happened in the meantime: it overwrote the six seams, called `startPageDocumentModules` and added the resize listener, and only then did the caller's `.then` read the claim and throw the result away. Everything after that await is shared — the seams are fields on a module-singleton scope, and the start sequence resets that scope and installs the document's listeners — so a mount disposed while its chunk was in flight was writing over a mount that owns the page. The claim is now re-read the instant the import lands, before any of it, and the build returns null. `ready` for such a mount resolves rather than rejecting. Nothing failed: the caller asked for the terminal and then asked for it to go away, and the chunk arriving afterwards is not something for the error overlay to name. Before this it rejected with a TypeError from `startSelectionMenuButtons` reaching for an emptied host. F2. The rejection handler called `release()` unconditionally, emptying a host the mount may no longer own. It now releases only when the page is still its own. Pins, both red first. In happy-dom: mount, dispose, then await ready — no listener, timer or frame added while it resolves, the six seams unchanged, `terminalGeneration` unmoved because the start sequence never ran, and the page free for the next mount. Without the fix that case rejects with the `startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight case now reads the page's listeners with no terminal on it and compares them against a page that mounted once and disposed once; without the fix the abandoned mount leaves `window error` and `window resize` behind, because the second mount's scope reset nulls the first mount's reporter undo. The listener snapshot helper is shared with the mount-and-dispose case rather than written twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): give the render fixture's server and scratch tree back when it cannot start CodeRabbit on the render fixture, plus its note on `release`. The fixture. `chromium.launch` is the last step of the setup and the one that fails in practice — no Chromium on the machine, an `ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle server is listening and the scratch tree is on disk. Rejecting there left the caller without a handle, so `afterAll` had nothing to close and both stayed allocated; the listening socket is the one that bites, because an open server handle keeps the vitest worker alive after its last test has reported. The setup after `mkdtemp` is now wrapped, gives back whatever it managed to take, and rethrows the original error rather than anything the cleanup raised. The normal close path awaits the server-close callback instead of firing it. `release` in the page mount. The ownership check covered the claim but not the two lines that make the terminal disappear, so a release that skipped the claim would still empty the host and drop its class. The check now guards the whole function, and round 5's caller-side check is gone as a duplicate of it: one rule, inside the thing it governs. Both existing callers are unchanged in behaviour — the synchronous planting catch always owns the page, and the rejection handler was already guarded. Pinned red first. The new case points the launch at an executable that is not there, then asks the port the fixture actually served on for a connection and reads the scratch directories in the temp dir. Without the rollback the port still accepts and the scratch tree is still there; with it, neither. The port is recorded by wrapping the real `createBundleServer` rather than standing a double in front of it, and the case asserts a server was created at all, or the refusal would mean nothing. Two oracles were discarded on the way. `rejects.toThrow()` with no argument passes for a build that broke for its own reason, so the rejection is matched by message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not `TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both arms and agreed with everything; it also still lists the handle at the moment the close callback runs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): read the render fixture's rollback in a temp root of its own Two defects in the case I committed incb1833e675, both found by running it. The anti-slop gate refuses module mocking, and it is right to: the case recorded the served port by mocking the harness module around the real `createBundleServer`. Gone, with no disable. Its replacement read the shared temp directory for the fixture's scratch prefix, which the render check next door writes to from a worker of its own. So the case watched that tree appear and be swept up mid-run and called it a change: one red in four alone, and red in the full suite, where the two run together. `TMPDIR` now points at a directory this worker made, so the fixture's scratch tree lands somewhere nothing else writes and what is left in there afterwards was left by the setup under test. The failed launch also leaves Playwright artifacts and a browser profile in there, which are Playwright's to clean, so the reading is filtered to the name the fixture gives its own trees. The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as Node spells it, and read a tick after the close callback, because the handle is still listed while that callback runs. Both halves now fail on their own without the thing they measure: with no rollback at all the socket count is one above its baseline, twice out of twice; with the rollback but no `rm`, the scratch tree is still there. Three green runs with both. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the started document to the mount in the turn that started it Round 6's two LOW items, and the pins for the owner-checked release. LOW 1. `started` was assigned in the `.then` after the build, a microtask later than the start sequence and the resize listener it installs. A dispose in that window found nothing started, skipped the teardown and released the page with the document still running on it. The build now takes an `adopt` callback and calls it as its last statement, inside the guarded region, so whoever has to undo the start is holding it before that turn ends. Pinned by queuing the dispose behind the document import the build awaits, which lands in exactly that window: without the change the started document's resize listener survives the dispose, five red runs out of five. The owner-checked release, which landed in8b37221b57without a pin of its own. The one path that reaches a mount's cleanup holding someone else's page is a rejected import: everywhere else the build re-reads the claim after its await and stops, but a rejection never gets that far. So the pin drives that — the chunk fails for the first mount only, the mount is disposed while pending, a second one is built into the same element as Reload does, and then the first rejection arrives. Without the guard inside `release` it empties the live mount's host: three red runs out of three, on the markup. It also disposes the abandoned handle a second time afterwards and asserts nothing moves, which is LOW 2's missing pin for round 5's F2. That case is its own file because the import has to fail before the mount module loads, and the mocking the failure needs is only permitted in `.test.ts` — the anti-slop override does not cover `.test.mjs`, which is what refused the port recording in the render fixture's case. It fails once, so the mount that replaces it gets real modules and is a live document worth protecting; its own resize listener is the witness that it started. Two oracles were dropped. Vitest reports its own message when a mock factory throws, not the one thrown, so which import failed is read from the factory's counter instead. And a counter of successful factory calls read zero even though the second mount got a working document, which measures vitest's caching rather than this code; the live mount's listener replaced it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the listener wrappers the mount pins install The mobile tests-typecheck ratchet was red on63eb8a40ae: six TS7006 implicit `any` parameters in each of the two mount pins, from arrow functions assigned over `window.addEventListener` and `window.removeEventListener`. An overloaded method gives an assigned arrow no contextual parameter types, so each wrapper's `type`, `listener` and `options` were implicitly `any` under `tsconfig.test.json`, which the product typecheck does not read. Both wrappers now take their parameters from the bound original as `Parameters<typeof realAdd>` and spread them through, so the signature is the real one rather than three widened parameters. No casts and no `any`. Re-verified that the change did not quietly disarm either pin, because a recorder that counted nothing would also go green: with `release` unguarded the rejection case still fails on the live mount's markup, and with the adopt deferred by a microtask the single-mount case still fails on the started document's resize listener surviving its dispose. The ratchet itself is the finding worth keeping. It is not part of the mobile `tsc` the rest of my gate set runs, and it had dropped out of that set when these folds began, so three reports listed the other ratchets and not this one. It is back in, and stays in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart Round 7's five items. 1. The queued-dispose case's precondition was vacuous. It read the host for a missing container, which dispose empties on every path, so a build that returned straight after its ownership check satisfied it. The wrapper now counts resize adds and the case asserts exactly one, which is the document having started. Red under that mutation, on the count. 2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that also refused replaced the error the caller needs — the reason the setup failed. The rollback is best-effort now and the original error is what comes back. 3. That cleanup stopped at the first throw, so a browser refusing to close took the socket and the scratch tree with it, which is the leak the rollback exists to prevent. Each of the three is asked independently and the first failure is rethrown after all three have been tried. 4. The rejection case restores its `window` patch in a `finally`, as its sibling does, so a failure part way through no longer leaves the patched functions behind for everything that runs after it. 5. `dispose` left `started` set. `send` reads it, and what it holds names the page's one set of document modules, so a stale handle could route a host command into whichever document is live next. Nulled, and pinned: the stale handle pings, and with the old code the *live* mount's `receive` answers `pong`, because the scope's seam belongs to it by then. The precondition is the live handle's own ping being answered, so the silence is the stale handle declining rather than the command doing nothing. Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup itself, reachable only by making a browser or a socket refuse to close, and standing something in front of Playwright to do it is what the anti-slop gate refuses in this file's suffix. The rollback's own pin still covers the path that matters, and both changes are read by it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): emit the terminal document as a factory Ruling 22, commit 1 of C7.5b. The generator's concatenation already gave the 38 modules one function scope with one local `scope`; naming that scope a function is what makes it the shape both hosts run, and what will let the page have its own state per mount instead of a module singleton with a reset between them. `createTerminalDocument(host)` is emitted around the same module bodies, in the same order, followed by the same start sequence. It then declares `stop`, which calls every module's stop in reverse order and takes back the frames the document is still owed, and returns `{ send: handleMsg, stop }`. The native document is that function plus one call with no argument, which is what the WebView has always run: no argument means every seam is the window read it already did. `createTerminalDocumentScope` takes the host and spreads the hooks it names over the window defaults, filtering undefined so absent and present-but-undefined mean the same thing. The emitted scope declaration is the one line the host reaches, so the generator rewrites it and refuses if the line it expects is not there — a rename would otherwise leave every call on the defaults with nothing to say so. The golden moves by the wrapper and that one line, and by nothing else. 108,329 to 108,831 bytes, the whole diff: -(function() { +function createTerminalDocument(host) { - function createTerminalDocumentScope() { - return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() }; + function createTerminalDocumentScope(host = {}) { + const named = Object.fromEntries(Object.entries(host).filter(([, hook]) => hook !== void 0)); + return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams(), ...named }; - const scope = createTerminalDocumentScope(); + const scope = createTerminalDocumentScope(host); -})(); + function stop() { + stopSurfaceTouchGestures(); + stopTapDispatch(); + stopSelectionOverlay(); + stopNormalBufferSmoothScroll(); + stopHostNotify(); + stopTerminalInit(); + stopWebglRecovery(); + stopFitScale(); + stopViewportTransform(); + cancelDocumentFrames(); + } + return { send: handleMsg, stop: stop }; +} +createTerminalDocument(); The byte golden and the payload hash are re-pinned once: 726,363 to 726,865 bytes, sha256 9950f177 to c7bbcb0b. Four test files sliced the document with their own copy of the IIFE bounds, which ruling 17 allows moving. They now share one reader in the test-support module beside the one that locates a single module, and that reader names the factory and its call. Every assertion is unchanged. The module-order guard and the region reader compare against the text the document carries rather than a raw emit, since the scope module is the one the generator rewrites; both go through one exported function so neither can describe the rewrite differently from the generator. `TerminalDocumentHostSeams` and the new `TerminalDocumentHost` moved to `document-host-seams.ts`, which owns the six functions they type. Types emit nothing, so the golden is unchanged by the move; it keeps `document-scope.ts` inside its 300-line cap with no disable and no bump. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): emit the page's terminal document factory beside the WebView's Ruling 23, and the first half of C7.5b commit 2: the artifact the page will import. The page cannot run the native script, because building a function from a string needs `eval` and the page's policy refuses it, and it cannot run the modules either, because they are one singleton while the whole point of the factory is a scope per call. So one emitted body gets two wrappers. `buildTerminalDocumentFactoryBody` is now the shared half: the modules in order, the start sequence, the stop handle and the return. The native script wraps it in the declaration and the trailing call, exactly as before. The new `terminal-webview-document-factory.generated.ts` wraps the same lines in a `@ts-nocheck` module whose only other content is the type import and the annotated signature. One generator run writes both, so the page's factory cannot be a build behind the WebView's. `@ts-nocheck` covers this one generated file. Every line of its body is esbuild output from a module that was type-checked at its source, with `declare global` blocks and type re-exports already erased and constants already substituted; the one line a caller reads is the signature, and the generator writes it with its types. `TerminalDocument` joins `TerminalDocumentHost` in `document-host-seams.ts` as the shape the factory returns. The pin is byte equality. `document-factory-artifacts.test.ts` strips each wrapper and holds the remaining text equal, so the byte golden pins the page's artifact by construction rather than by a second golden; it also reads the file on disk against what the generator would write now, since that file is gitignored and built by postinstall, and it refuses a trailing call in the page's copy, which would start a document as the module was imported. The path joins `.gitignore` and the oxlint ignore list beside the engine artifact. The consumer census gains the generated file by name: it is the document, and its one import is the host contract its signature is written against. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): call the document instead of starting its modules The page's half of ruling 23, and ruling 24. The mount plants the markup and calls the factory; the handle is `send` and a `dispose` that stops it. The document is a function, so the page holds an object per call and nothing else. Deleted with the singleton it was written for: `page-document-modules.ts`, the claim token and `liveDocument`, `release`, the owner-checked `dispose`, the second-mount refusal, `resetTerminalDocumentScope`, the `adopt` callback, `ready` and every pending-import path. All of it existed because two mounts shared one module-level scope and because the handle had to come back before its import did. A call is a document now, so a second mount cannot reach the first one's state and a caller's cleanup cannot arrive before there is something to clean up. The second-mount refusal is not replaced by a one-line guard: with a scope per call there is no shared state left to refuse for, and a host element with two documents planted in it is the caller's own doing, visible on the screen. Ruling 24 splits `message-bridge` by what it is, which is what made the page able to run this text at all. Two more seams, eight now: `installHostTransport`, whose window default installs the `message` listeners on window and document and hands back their removal, and `hasEngine`, whose default is the `window.Terminal` the engine bundle installs. The page answers a transport that installs nothing, because its transport is the handle, and an engine that is always there, because the engine is the import above. So the page no longer takes the shell's frames or reports a missing engine on every mount, and `stopMessageBridge` takes the listeners off — the WebView never removed them, which ruling 21 asks for. The refit the bridge happened to own moves to `fit-scale`, which is whose work it is; both hosts start it, and the mount's hand-copied five calls are gone. The engine's disposal moves into `stopTerminalInit` for the same reason: the mount cannot reach the scope any more, and a stopped document's terminal is a WebGL context nothing will read again. The start sequence the generator emits is now inside the document's own undo: a start that throws runs `stop` and rethrows, so neither host can be left holding a listener from a build that failed. That replaces the deleted entry module's unwind, and it covers every start rather than the four that had one. Readiness arrives the same way on both hosts. The document posts `web-ready` through `postToHost`, which the controller already handles, so the mount-side `confirmWebReady` is gone. That flush is also the one caller that reaches `post` before the effect has a handle, which is why the component's queue stays and now says so. The golden and the payload hash move, 102 diff lines: the two seam defaults and their state fields, the reset gone, `startFitScale`, the disposal, the bridge over its seams, and the start sequence inside its try. Tests: the seam tests and the unwind test move to the factory and the derived start sequence; the frame registry builds a fresh scope instead of resetting one; the two mount test files and the page entry's order test go with their subjects. The render check keeps every behavioural case and loses two whose subject the static import removed — a Reload while the chunk is in flight, and a chunk that will not load, which is now the route's chunk rather than the document's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): stop censusing state a call of the document already isolates Ruling 22 answers what ruling 21's state half was for. A module's top level is emitted inside the factory, so a `let` there is one binding per call — which is exactly what moving it onto the scope was achieving. The census that refused it, and the planted-module precondition beside it, go. The effect half stays, and the distinction is what a stop can reach. An effect in a module body runs at the position its module is emitted rather than in the start sequence, so no stop function undoes it and each call leaks another one. A binding leaks nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): stop the derived start sequence warning on every suite run The helper reaches its neighbours through a variable specifier, which the bundler answers by rewriting as a glob — and it refuses to glob the directory the import is written in, so every suite that loads this file printed the refusal twice. `@vite-ignore` leaves the specifier alone and the module runner resolves it, which is what was already happening. An extension does not help: with one the refusal becomes the own-directory rule, and the path alias is not resolved for a runtime specifier at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): separate the two commits inside the closure reading The factory arriving is not the whole -1,890. Making the document a factory put the `host` argument on `createTerminalDocumentScope`, which is this lane's only edit to a module the closure already carried, and that alone is +80. Both numbers are in the note now, so neither commit's cost is read as the other's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a document's elements from the host it was planted in The last thing two documents on one page shared. Ruling 22 gave each call its own scope, but the element reads were `document.getElementById` and the ids are in the markup every host plants, so the second document's start sequence took the first host's surface, overlay, handles and menu — two documents driving one terminal, with the second host left empty. Reachable, not theoretical: expo-router keeps the outgoing screen mounted for the length of a stack transition, so two routes that both hold a terminal have two live documents on the page while the animation runs. `root` joins the host argument and `elementInRoot` is the one reader; the ten reads in runtime-constants, surface-swap, selection-state-and-eviction and text-scaling go through it. No id is renamed and nothing is refused: two documents on one page are two terminals. Two deviations from the ruling, both about *when* the default is read. `root` is `ParentNode | null` with null meaning "the page I am in", rather than defaulting to `document`: a data default is evaluated whenever a scope is built, which put a DOM read into every slice evaluation and took eight keyboard-avoidance cases down with a `ReferenceError` in their `vm` context. Null defers it to the read, which is the rule the eight seams above it already follow. And the reader lives in `document-host-seams.ts`, which declares the type, taking the root as an argument: in `document-scope.ts` it was four lines over the file's 300 (no bump, no disable). Red first, and the red was the second document: with a page-wide read the second engine opens on an element outside its own host. `document-host-root.test.ts` plants two hosts, starts a document in each, and reads which surface each engine was opened on through the `createTerminal` seam, because the scope is not reachable from outside. Falsified again after the fix by pointing the emitted reader back at `document`: red, one case. Two neighbours checked while here. The document-level touch listeners are already host-scoped, because every handler tests its target against the scope's own surface, overlay and handles, which are now this document's. `window.__engineErrors` is the one page global left, and it is now kept rather than replaced per mount: a capped diagnostic buffer, where a second mount was costing the first its captured lines. Golden 27 diff lines, hash and length repinned: the reader, the `root: null` default, and the ten reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the two-host engine double as the shape the seam returns The double was reaching `createTerminal`'s return type through `as unknown as Parameters<typeof queueMicrotask>[0] & never`, which the type-aware gate reads correctly as an intersection with `never` and which was a cast standing in for naming the type. `TerminalDocumentTerminal` names it. Every member the type declares is present — the ones `init` and the start sequence reach do something, the rest answer in the shape their caller reads — and the shape needed no narrowing to accept a double. Two things the type does not declare moved off it: where `open` was called is handed back beside the terminal rather than exposed as a second getter, so the literal carries nothing excess, and the buffer gained the `getLine` the type requires. No cast, so nothing to write a SAFETY line about. Still red without the fix, checked again after the retype by pointing the emitted reader back at `document`: one case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): filter a document's page-wide touch listeners to its own host (OTA phase C, C7.5b round 1) Round 1 H1. The dispatcher's four listeners are on `document`, so with two documents on one page (legitimate since `712daa80e6`) each is handed the other's touches, and the two-finger branch acts before any target filtering: a pinch in host B posted `mobile-clip-cancel-by-pinch` from document A and dropped A's selection. Fixed at the source, one predicate beside `elementInRoot`, asked once at the top of each handler rather than inside a branch. `root === null` is the WebView, where the document is the page, so it answers yes to everything and the native document is unchanged. `e.target` is the element the finger went down on for the life of the touch, so a select-drag travelling outside the host still answers yes on move, end and cancel. Census of every global listener install under `src/terminal/document/` (non-test): - `tap-dispatch.ts:241-244`, four capture-phase `document` touch listeners (touchstart, touchmove, touchend, touchcancel): MUST be root-filtered; this fix. - `document-host-seams.ts:165-166`, `window`+`document` `message` in `installWindowHostTransport`: WebView-only. It is that host's transport seam default and the page installs nothing (ruling 24), so no page carries two. - `fit-scale.ts:163`, `window` `resize`: page-wide by nature. A viewport change concerns every document on the page and the event has no target in either host; both must refit. - `webgl-recovery.ts:104`, `document` `visibilitychange`: page-wide by nature. Backgrounding concerns every document on the page; its target is the document. - No document-level mouse, wheel, keyboard or selection listener exists: those are all on `targetSurface` or the menu buttons, read through `elementInRoot`, so they are already inside their own host. Red-first, the reviewer's own repro in `document-host-root.test.ts`: A and B both in select mode, a two-finger touchstart in B's surface. Before: 2 failed (A posted the pinch cancel too, and the control in A's own host cancelled B). After: 3 passed. The control keeps the assertion honest — the same touch inside the document's own host still cancels its selection. Golden and payload hash move (regen is a review event): six hunks, +22/-1. `eventTargetInRoot` emitted after `elementInRoot`; `touchIsThisDocuments` after the CAPTURE constants; the three-line guard at the top of each of the four handlers; `onDocumentTouchCancel()` becomes `onDocumentTouchCancel(e)`. Document 728,119 -> 728,589 bytes, sha256 5b65315b... -> 1556f532... Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): unit-test the page mount's three paths no happy path reaches (OTA phase C, C7.5b round 1) Round 1 M2. `terminal-web-document-mount.ts` had no unit test: the only reading of it was the render check, which drives the whole page bundle in a browser — right for behaviour, too coarse for three lines that only a failure reaches. The body's "five test files whose subjects no longer exist" is wrong for three of them. What ruling 22 deleted was the machinery (the claim token, `liveDocument`, the owner-checked dispose, the second-mount refusal); these three subjects survived it and lost their only cover: - both engines disposed when a swap never committed (`terminal-init.ts:203-213`); - the host given back when a start throws (`startDocumentOrGiveTheHostBack`); - the component naming that throw's cause (`TerminalWebView.web.tsx:83`). `terminal-web-document-mount.test.ts` (happy-dom) covers all three against the real generated factory. Only the factory's *arrival* is mocked, delegating to the real `createTerminalDocument` except for the one case that makes a start throw, so no stub stands in for the program under test. Six cases, each red against a deliberately broken line: - two distinct terminals both disposed. Broken `new Set([scope.term, scope.committedTerm])` -> `new Set([scope.term])`: expected [1,1], got [0,1]. - the same terminal disposed once. Broken the set -> a plain array: expected 1, got 2. The pair is the dedup's own oracle; either half alone passes for the wrong reason. - the host emptied and the class dropped on a throw. Broken by deleting the two lines in the mount's catch: host still carried `#terminal-container`. - control: a live document keeps the markup and the class, so the two assertions above cannot pass for a mount that planted nothing. - `onEngineError` gets `terminal document failed to start - engine missing`. Broken by deleting the component's `receive` in its catch: expected one message, got none. - control: nothing reported when the document starts. The engine double moves to `document-terminal-double.test-support.ts` and both readers of the seam share it; a second hand-written copy of thirty members would drift as the shape grows. It now counts disposals beside reporting `open`. Terminal suite 67 files / 625 tests -> 68 / 631. No product line changed, so the golden and the payload hash do not move. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin both generated wrappers, and say what a shadow root would break now (OTA phase C, C7.5b round 1) Round 1 M1 and L2. M1, the mount's stylesheet comment was one version behind: it said the document reads its elements with `document.getElementById`, which `712daa80e6` replaced with `elementInRoot`, and drew its shadow-root conclusion from that read. Both halves re-derived rather than reworded. A shadow root no longer breaks the reads (`elementInRoot` is a `querySelector` under the host, which a shadow root answers); it breaks this sheet, because a rule in the document's head does not cross a shadow boundary, so it would have to move inside each root and be parsed once per host instead of once per page. L2, `document-factory-artifacts.test.ts` anchored the page body at `): TerminalDocument {` and nothing else, so the header, the `@ts-nocheck` line, the `import type` and the parameter's own line could all drift with the test green — and that signature is the one line a caller of the page's artifact reads. Both wrappers are now literal lines: nine for the page (header, directive, import, blank, the three-line signature) and one plus two for the native script (declaration, closing brace, trailing call). Literal rather than the generator's own constants, which would only agree with whatever it emits. Red controls, each with the generator changed and then restored: - the page's `import type` reordered to `{ TerminalDocumentHost, TerminalDocument }`: 2 failed ("the page module opens with its wrapper", and the on-disk reading). - the native trailing call changed to `createTerminalDocument({});`: 1 failed ("the native script closes with its wrapper"). The old anchor caught neither. No emitted line moved: golden and payload hash unchanged, terminal suite 68 files / 631 tests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): thread the document scope and bundle the page's script Ruling 25. The terminal document stops being a string the build machinery assembles and becomes ordinary TypeScript: every module function that reads document state takes `scope: TerminalDocumentScope` as its first parameter, `document-scope.ts` exports the types and `createTerminalDocumentScope(host)` and nothing else, and `tsc` is the oracle that the threading is whole (a missed parameter is a type error). `create-terminal-document.ts` is hand-written, not emitted: it builds the scope, runs the eleven starts inside a try that unwinds on a throw, stops the ten in reverse with `cancelDocumentFrames` last, and returns `{ send, stop }`. `native-document-entry.ts` is one statement. The concatenator becomes an esbuild IIFE bundle of that entry at the Chrome 74 floor, written as a string the same way the engine artifact is. The byte pin cannot survive that and does not try to: esbuild merges module scopes and renames the threaded parameter (`scope` -> `scope2` where two modules collide), so the document's text is no longer a stable artifact and the behavioural suites are the proof. `native-document-bundle.test.ts` evaluates the real bundle and reads three behaviours the phone depends on: it announces `web-ready`, an init message opens the engine inside `#terminal-surface`, a ping is answered, and a missing engine global reports fatally instead of starting. Rewritten tests, old oracle -> new oracle: - host-seams, write-queue, document-frame-registry, document-parse-time-effects: evaluated a region of the generated text -> import the module and pass a scope the case builds. - terminal-webview-engine (WebGL recovery), terminal-webview-theme, terminal-webview-text-zoom, terminal-webview-query-reply, terminal-keyboard-avoidance-webview: a `vm` evaluation with injected globals -> the imported functions over a scope whose seams are the case's own doubles. - terminal-webview-url-tap: a function extracted out of the document's text and evaluated -> the document's `osc-link-tap` exports imported directly. - terminal-webview-reflow, terminal-webview-scroll-routing, terminal-path-tap, terminal-webview-tap-routing, terminal-webview-wheel-scroll: assertions over the assembled document text -> the same assertions over the module's own source, read through `document-module-source.test-support.ts` (TypeScript, so no semicolons). - document-host-root, document-start-unwind: imported the generated factory -> import `create-terminal-document`. - terminal-webview-consumer-census: the generated factory was a census exception -> it no longer exists. - config/scripts closure tests: re-measured for the bundled document. Deleted with the machinery they served: the concatenator's emit/substitute/order code, `terminal-document-module-order.mjs`, the document fixture builder, `generated-document-region.test-support.ts`, the byte golden and its identity test, the payload-hash pin, `document-factory-emit`, `document-factory-artifacts`, `document-module-order`, and the generated factory artifact with its gitignore and lint-ignore lines. Two assertions the tests no longer need: the published contrast floor arrives unvalidated from a host of unknown version, so `TerminalDocumentThemeMessage` types it the way the router types its other wire fields and `normalizeTerminalContrastOverride` remains what decides it is a number; the recovery harness holds its timer callback in a wrapper rather than asserting a narrowing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): dissolve the document's constants module into its owners `document-constants.ts` existed because the document was a string: a string cannot import, so the generator substituted JSON literals into the text and the web page imported the same bindings to keep one source. The document imports now, so the indirection is a re-export shim over four real owners and each site reaches the owner instead. - `terminal-theme.ts` takes the background fallback from the theme's own `colors`. - `url-tap.ts` imports the two patterns and the length cap from `terminal-webview-url-tap.ts`, which is where the page's copy reads them, and the three local aliases go with the substitution they were shaped for. - `text-scaling.ts` and `document-scope.ts` take the presets from `storage/preferences`. - `terminal-init.ts` and `document-scope.ts` take the caret options and the built-in theme from `terminal-webview-html/theme.ts`. One test oracle moves with it: the URL tap's "both copies spell the pattern identically" case pinned the substituted assignment line, and now reads the document module's import of the page's own constant. The resolver cases that compare the two behaviours are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): count only the fingers inside this document's own host (OTA phase C, C7.5b round 2) Round 2's residual of `9824145e1f`'s class, one level in. `eventTargetInRoot` settles whose event it is; every branch then counts `e.touches`, which is every finger on the screen. A finger resting in host A is therefore B's second finger: a one-finger touch in B's own surface reads `length === 2`, latches a pinch and drops B's selection, and on touchend `length === 0` is never true so B's surface tap never fires. `touchesInRoot(root, touches)` beside `eventTargetInRoot` returns this document's own fingers, and every count and index reads through it. A list rather than a count, because `touches[0]` and `touches[1]` are page-wide in exactly the same way as `touches.length` — the first finger on the screen may be the other terminal's. `root === null` is the WebView, whose fingers are all its own: the list is returned untouched, so nothing is allocated on a path that runs at frame rate. Census of every `touches` / `changedTouches` / `targetTouches` read under `src/terminal/document/` (non-test). There are no `changedTouches` or `targetTouches` reads at all; every read is `e.touches`: - `tap-dispatch.ts`, 15 reads across the three handlers that take an event (`[0]`, `[1]`, `.length`, and the list handed to `touchById`): MUST be filtered. The document listens on `document`, so the event and its list are both page-wide. - `surface-touch-gestures.ts`, 18 reads across its touchstart, touchmove and touchend handlers: MUST be filtered. These listeners are on the document's own surface, so the event is always this document's — but the list inside it is still every finger on the screen, which is the whole defect. - `tap-dispatch.ts:21-24`, `touchById(touches, id)`: no filtering of its own. It reads whatever list it is given, and all three callers now hand it a filtered one; its parameter widens from `TouchList` to `ArrayLike<Touch>`. Red-first in `document-host-root.test.ts`, the reviewer's two repros, with the three product files at `aba99c3e4f` and the artifacts rebuilt: 2 failed / 3 passed (pinch cancel posted with one finger on B's overlay; `terminal-tap` never posted). With the fix: 5 passed. The pinch-inside-own-host control stays, and the first repro lands on B's menu pill rather than its surface, because a single finger on the surface dismisses a selection by design — on the pill, keeping the selection is the whole assertion. `document-host-seams.ts` also rewritten in the present tense where it read as history. Golden re-pinned: 19 hunks, +51/-33. `touchesInRoot` emitted after `eventTargetInRoot`; one `const touches = touchesInRoot(scope.root, e.touches)` at the top of each of the six touch handlers, and every `e.touches` read inside them now reads `touches`. Document 728,589 -> 729,152 bytes, sha256 1556f532... -> 02633389... Correction to `9824145e1f`'s message: it cites `tap-dispatch.ts:241-244` for the four installs, which at that commit are `261-264` (the line numbers are the pre-fold ones from the review). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): fold the document's never-written constants out of the scope Seventeen scope fields were never assigned after the factory built them. They were fields because the generator substituted them into one function scope and a module cannot import into a string; each is now a `const` in the module that owns it. - `escape-introducers.ts` holds `ESC` and `C1_CSI`, as the bytes rather than as `String.fromCharCode` calls a JSON substitution needed. - `write-queue.ts` owns the status dot, its two presentation selectors, the pattern and the DECSET tail limit. The pattern is a regex literal: a `new RegExp` at a module's top level is parse-time work, which ruling 20 refuses and the census measures. - `fit-scale.ts` owns `MIN_FIT_COLS`, `text-scaling.ts` the ends of the preset range, `tap-dispatch.ts` the press and tap thresholds, `selection-range.ts` the word pattern and `selection-overlay.ts` the edge-scroll distance and tick. Four functions stop taking a scope they no longer read: `isStatusDotPresentationSelector`, `endsWithStatusDotPresentationSequence`, `extractMouseModeScanTail` and, with `normalizeInitialData`, `isAltScreenActive`. `runtime-constants.ts` held no constants once they moved out, only the surface element read. That read is the first line of `startSurfaceSwap` now, which is the module the field is documented as belonging to, and the start sequence is ten calls rather than eleven. The engine error buffer becomes a seam, `capturedEngineErrors`. The WebView's `<head>` keeps its own: it opens before the engine script tag, so an engine that throws while loading is captured by something no document has started yet, and the first report quotes it. That is why the head declaration stays where the design said it would go — the page's mount answers the seam with a buffer per mount instead of assigning a window global, which is what the document no longer touches. Two of the page's three casts are gone, and tsc is what says so: xterm's cell attribute getters answer numbers, and `getLine(...).getCell` answers `undefined` rather than null, so the shape now describes the engine it was written against. The third stays with a narrower reason: `getCell` takes back the cell xterm allocated, and describing that parameter means naming xterm's whole cell type where the document declares the six members it reads. Comments that narrated the extraction — the scope table's `var` census, "the flip", "the main slice", "C7.1 extracts" — say what the code does instead. Oracles that moved with the constants: the reflow floor and the status dot now read the owning module's `const` rather than a scope-factory line; the write-queue harness resolves the module's one value import; the parse-time census asserts the stronger fact that no module does parse-time work, with the element reader aimed at every module and its presence proved by the planted case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): re-measure the session route's closure for the threaded document The reading in the closure test's note is against `origin/main` atec82173130, measured the same way on both sides: the route and its layout built as their own entries, the route's output taken minified. modules 4320 -> 4321 (+1) local modules 970 -> 971 (+1) minified bytes 3,768,122 -> 3,764,932 (-3,190) The +1 is three modules in and two out, which the note names. The bytes fall for two reasons the lane can point at: a threaded parameter minifies to one character where a shared object could not, and a constant folded into the module that owns it is inlined where `scope.X` was a property access the minifier had to keep. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep AsyncStorage out of the WebView document, and prove it The 13 KB the bundle grew is not esbuild's lazy `__esm` wrappers and not a cycle: there are none of either. It is a dependency that rode in. `text-scaling` reached `storage/preferences` for the text-scale presets, and that module imports AsyncStorage, so the phone's document carried AsyncStorage, `merge-options` and `is-plain-obj` — 11.6 KB of storage library inside a string with nothing to store, wrapped in esbuild's CommonJS interop. The old generator hid this: it substituted the presets as a JSON literal, so the import never reached the emitted text. The presets move to `terminal/terminal-text-scales.ts`, a leaf with no imports of its own, which `storage/preferences` imports and re-exports for the settings screen. The bundle: 113,442 characters, 3,465 lines, 47 inputs, none from node_modules was 120,217 characters with 6 node_modules inputs and three `__commonJS` wrappers the golden it replaces was 110,085 bytes `minify: false` stays, for the reason given: the overlay reports the line and column `window.onerror` hands it. Three cases join the bundle evaluation, and each was made to fail before it was kept: - A `set-theme` and a `write` before `init`, which is what a byte golden covered by accident. Read from the router rather than assumed: the theme applies to the scope and paints through the seam, the chunk normalises and queues, the pump returns because there is no terminal, and `init` then resets the queue and the mode scan so the early chunk is dropped and the init frame's own theme wins. Deleting `resetWriteQueue` from `init` makes the terminal write `early chunk\x1b[0mreplayed` — the early bytes ahead of the snapshot, which is the corruption the reset prevents. - The transport, both ways: the native document installs `message` on `window` and on `document` and keeps them, because the WebView never stops its document; the page's factory with a no-op transport installs none, so none of the shell's own frames are taken. Installing on one target fails the first half, installing a real listener the second. - What the bundle carries: no input from node_modules, no `__commonJS`, no `__esm(`. Pointing the presets back at `storage/preferences` fails it with the six inputs named. The build options become one object the census and the build share, so what is measured is what ships. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the closure reading for the text-scale leaf The presets moving to their own module adds one more to the page's closure than the reading recorded, and five bytes with it. Measured the same way on both sides. modules 4320 -> 4322 (+2) local modules 970 -> 972 (+2) minified bytes 3,768,122 -> 3,764,937 (-3,185) Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): separate the lane's closure reading from main's The byte figure in the note was measured before `origin/ota-c7-5b-document-factory` and the main it carries were merged in. This head reads 3,765,180; the 243 between the two are the touch-root predicates and main's #21687 momentum change, which are not this lane's to claim in either direction. Both numbers are named rather than one of them silently replaced. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): compare the start sequence with what the modules export, by name Round 1's finding on the census: completeness was a count, so a module exporting a start nobody calls red only as `expected 11 to be 10` and greened again the moment the literal moved with it. The two sets are compared by name now, with the order asserted as well: the starts the factory calls are every exported `start*(scope)` there is, the stops are every exported `stop*(scope)`, and the stops of modules that have both run in the reverse of the order their starts did. `cancelDocumentFrames` is held out of the set comparison and asserted by position instead — last, after every stop that might still hold a frame. `stopEdgeScroll` is the one exported stop the sequence does not call, and it is not a lifecycle undo but the overlay's own for a drag that is over; the test asserts `stopSelectionOverlay` reaches it rather than waving it through. The names come from the tree rather than the text, because a regex over the file would also match the sequence's own name in the unwind inside `startTerminalDocument`'s catch. Red-first control, `export function startReflow(scope)` added to `reflow.ts` and not called: the set comparison fails naming `startReflow`, where the count failed with a number. A second case plants the same shape against the reader itself, so the comparison is a measurement rather than an agreement between two empty lists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the bundle census and the artifact off one build Round 1's finding: the input assertion built the bundle while the wrapper assertions read the committed string, so under the presets-to-preferences control the rebuild red and `__commonJS` passed against a stale artifact. And `inputs.length > 40` was a bound, not a census. `terminalDocumentBundle()` returns the text and the module list from one build, and `buildTerminalDocumentScript` is that function's text — so the thing measured is the thing written. The case asserts the exact input count, no node_modules input, neither wrapper, and that the committed artifact equals what the sources build. A stale artifact now reds. Controls: the presets pointed back at `storage/preferences` fails on the inputs and on `__commonJS` in the same run; `MIN_FIT_COLS` changed to 21 without rebuilding fails the equality. Worth knowing for the next reader: an unused export or a dropped comment does not fail it, because esbuild does not emit either — the assertion is about what ships. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): show that the page's capture buffer is written, and say what writes it Round 1 read the page's `capturedEngineErrors` array as never written and asked for an empty readonly list instead. It is written, and the empty list would drop a line from every report the page makes: `startHostNotify` installs the reporter through `installErrorReporter`, which on the page is a `window` error listener, and the reporter appends each error it forwards before `reportEngineError` quotes the buffer back. `host-notify.ts` is the file that proves it; the comment on the mount said none of this and now says it. The pre-start window round 1 asked about is the half the page genuinely cannot have. The WebView's `<head>` opens its buffer before the engine script tag, so an engine that throws while loading is captured by something no document has started; on the page the engine is a static import of this module, so there is nothing to capture before the document exists. Red-first: two errors dispatched at a real mount, and the reports quote `captured: first failure` then `captured: first failure | second failure`; a second mount quotes its own line and not the first document's. Answering the seam with `() => []`, which is what round 1 asked for, fails the first assertion. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): put the engine doc block back on the engine seam, in the present tense Round 1's last two. The block describing how the WebView knows the engine is there had ended up above `windowCapturedEngineErrors`, one function too high; it is on `windowHasEngine` again, with that function's own note about the optional global folded in. Two references that had outlived what they named: the host-seams case cited `runtime-constants`, which this branch deleted when its one element read moved into `startSurfaceSwap`, and now cites modules the sequence still has; the query-reply harness narrated what its oracle used to be instead of what it is. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): reverse the stop order without mutating the list `Array#reverse` mutates, which the lint rule refuses and which would have left the census comparing a list it had just reordered. `toReversed` on the filtered copy says the same thing and cannot. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): put every document import back in its import block pullfrog caught `fit-scale`, where `scheduleDocumentFrame` sat below `MIN_FIT_COLS`. A statement walk over all 41 non-test modules under `document/` — the tree, not a grep, so a multi-line import or one inside a comment cannot hide — found one more: `host-notify` split its two `document-host-seams` lines around the re-export between them. Both imports moved up; the re-export stays where it was, below the block, with its comment in the present tense. The sweep reports no misplaced import across the 41 modules now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): name the haptics module inside the merged session-closure reading (OTA phase C, C7.5b) The merge's re-measured 4284 sat one above this branch's -40 added to PR B's +3, and the comment could only say main had drifted "a module of its own". It is `src/mobile-web-shell/bridge/bridge-haptics-notify.ts`, which C7.10 item E put on the session route after PR B recorded 4323 — so pristine main reads 4324 against the 4323 it holds, which is what #21908 re-pins. Named here as #21908 names it on main. Nothing measured changes: 4284 is the same number, and the module is in it by main's route rather than by anything this branch did. `haptics.web.ts` was already in the closure; the bridge module joins it. Verified by reading the closure's own module list rather than inferred from the count: both haptics modules are in `local`, with the artifact-level totals unchanged at 4284 / 934. Which is why the reading is re-measured and not summed. A merged number arrived at as -40 plus +3 would have read 4283 and been wrong about a module neither side of the merge touched. After #21908 lands, a further merge of main reconciles the two comments. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): stop every document a host-seams case started CodeRabbit's finding, and it measures: `startedScope` runs the whole start sequence and no case stopped it, so the `afterEach` restored the globals and left the listeners. A start installs six on `document` and `window` — the dispatcher's four capture-phase touch handlers, the fit's resize and the recovery's visibilitychange — and those are page-wide by nature, so a document nobody stopped keeps answering events in the next case, with a scope that case has never seen and host hooks that belong to the case before it. Every started scope is tracked and stopped in the existing `afterEach`, before the globals go back, because a stop reads the scope's own seams and one of them is a window read a case may have stubbed. Measured by counting `addEventListener` and `removeEventListener` on both targets across the file's run: 18 added and 0 removed before this, 18 added and 18 removed after. For a single case it is 6 and 0 against 6 and 6. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): pair each doc block with the declaration under it Two comment placements, both mine and both from the merges. The closure test's `SESSION_ROUTE_MODULES` block opened twice: my conflict resolution kept the opener that was above the marker and supplied another with the replacement text, so the file carried a literal `/**` inside the block it opens. Nothing flags that, because it parses as one comment. Swept the rest of the files the merge touched for stacked openers and for stray markers; there are none. In the host-seams case, `startedScopes` and its block landed between `startedScope`'s doc comment and the function, which left the function undocumented and stacked two blocks on the const. The const moves above the function's doc, so each comment sits on what it describes. host-seams 10 tests, the closure test 3, both exit 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb