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