Skip to content

feat(ios): decide the presented surface from a captured tree - #3578

Open
thymikee wants to merge 1 commit into
feat/3565-presented-surface-carriagefrom
feat/3565-presented-surface-rule
Open

thymikee wants to merge 1 commit into
feat/3565-presented-surface-carriagefrom
feat/3565-presented-surface-rule

Conversation

@thymikee

@thymikee thymikee commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds the rule that decides whether an iOS capture read a modal presentation over the app's own content
(#3565), the golden table pinning its answers, and the publication that carries them. Layer 2 of 3. 8
files, +951/−2.

The rule and the publication are one layer on purpose: check:production-exports refuses an exported
rule with no production consumer, and it is right to. A layer that computes a fact nobody can read is
not a shippable unit, so the first consumer is here rather than one layer up.

The TypeScript rule lives in ios-snapshot-engine/engine.ts, not its own module. A new module the
engine imports is a new eager edge that ADR 0027's no-growth rule refuses — thirteen carried entries
would have grown by one module and 70 eager statements, and the rule is pre-existing code, so no split
explains it. The engine is also the module that publishes this fact and already derives the
completeness statement the rule reads, so the placement is not a workaround for a count.

absent is never the default, because #1831 arms a dismissal confirmation on it. It requires that the
producer stated the capture was not capped, that every node carries the UIKit class name the rule
reads, that each node is numbered once so the parent links the walk follows name the node they mean,
and that the one content window under the app root holds exactly one UITransitionView. Positive
evidence — a UIDimmingView, an _UIAlertController* root class, or a Sheet/Drawer/Alert/
Dialog element type under its name or its raw Element(<n>) spelling — answers presented from one
node, so a capped capture still reports what it read. Producers that publish no class names (every
XCTest runner tier) answer presented or unknown, never absent. Completeness arrives as
'complete' | 'capped' | 'unstated' from the producer's truncation statement, never inferred from the
tree. The rule reads a dimming view without its enablement, unlike the covering-presentation cut in
geometry-policy.ts that shares the container class — no parallel classifier, the same vocabulary
extended at that owner.

Evidence is matched on the node the walk stood on, not on the number it carries. Excluding a
keyboard host's subtree therefore removes the occurrences that walk reached, and an app window's own
dimming view that happens to share a number with one of them keeps its evidence. Numbering is only
required for the strong answer: absent claims something about every window the producer read, and
parent links cannot tell two nodes of the same number apart, so a producer that numbered a node twice
cannot support that claim. The most a collision costs is the answer the rule declines to make.

Two choices worth a reviewer's eye: system windows are a denylist (an unlisted host carrying a
dimming view of its own would read presented; none appears in the captures taken here) so an app
hosting its scene in a UIWindow subclass stays readable; and Element(<n>) must be bare digits,
because Int("+7") == 7 would otherwise let each language's parser decide the answer.

The Swift twin, its parity test, and the runner's own reading of its acquisition are in layer 3, where
the runner acquires trees. Both languages read the one fixture, which is here, so the layer-3 parity
test cannot drift from it.

Evidence coverage (iPhone 17 simulator, RN test app)

Captured live through the stack's final state at c06d8ec9a. Rows marked live were read off the device;
the rest are golden cases, said so plainly. Every row here is produced by this layer's TypeScript engine,
which is the producer the CLI talks to on a simulator — the runner half lands in layer 3.

Presentation Answer Basis
Home, Catalog, automation lab — one content window, one container absent live, --raw and every projection
The same lab after the sheet is dismissed absent live
RN pageSheet sheet — window holds 2 containers, dimming views at 4 and 134 presented live
That sheet through the projected cut (6 nodes) and the interactive cut (4 nodes), neither of which publishes a dimming view presented live — the fact is read off the acquisition, not the projection
Home with the Expo dev-menu floating button on screen (a second content window) unknown live — a window this rule cannot name
Any screen through the private-AX backend (--actions, 0 class names published) unknown live — the class-blind gap
Sheet resting at an undimmed detent presented golden case, not device-captured
RN Alert.alert (own window, no dimming view) presented golden case, read from a captured tree
Alert whose producer names only Element(7) presented golden case, both readings agree
Full-screen / custom modal, no dimming view unknown golden case — nothing positive, no guessing
SwiftUI .sheet presented expected same UIKit container as a page sheet; not device-captured
SpringBoard system alert out of scope resolveBlockingSystemModal stamps .presented (layer 1)
Capped, unstated, malformed numbering unknown, or presented on evidence it did read golden cases

Two things the live run is worth stating out loud. The dev-menu floating button puts a real
EXDevMenu.DevMenuFABWindow beside the app window, and the honest answer for that screen is unknown —
the four absent rows above were taken with it disabled
(defaults write com.callstack.agentdevicelab EXDevMenuShowFloatingActionButton -bool NO), which is the
single-content-window shape the rule reads absence from. And the interactive cut of the open sheet
publishes four nodes, none of them a dimming view, while the fact still answers presented: that is the
failure mode this fact replaces, since a consumer reading the published tree would have had to answer
absent on a screen with a sheet over it.

Validation

777d9b73a, verified at this head rather than at the stack top: check:production-exports passes (the
head before this re-split failed it, reporting collectPresentedSurface as an export with no production
consumer), swift build is clean, pnpm build and pnpm typecheck are clean, and vitest over
packages/capture-kit, packages/kernel and packages/platform-apple is 2,963 green, eager-closure
budgets included. A repository-wide local gate is deliberately not claimed for this head: four other
worktrees on this host are running subprocess-heavy suites at the same time, and the failures that would
produce earlier in this stack — an ADB connect failure, a daemon startup timeout, one abort-timing case —
were contention from exactly that, each passing in isolation while failing under load. What the previous
head genuinely failed was check:production-exports, which is the check above.

The 18-case contracts/fixtures/ios-presented-surface.json gives each case a class-aware and a
class-blind answer, and both parity tests read that one file, so drift in either language or vocabulary
fails. Claims outside the vocabulary are pinned by cases and confirmed by mutation: the element-type
numbering is held at its owner (tree.test.ts decodes each raw value through the bridge decoder and
compares to the table); completeness is pinned on a class-aware tree, the only shape that reaches the
absence branch; a cycle case pins the visited guard (without it, classification spins forever); a
partially class-blind tree — the shape the bridge emits when it reads no class — pins the class-name
requirement (delete it and that case answers absent); and three malformed-numbering cases pin the
boundary just described — an app-side dimming view sharing a number with a node the keyboard-host walk
reached keeps its evidence and answers presented (it had been asserting unknown, which was asserting
the bug), one hanging under the shared number answers unknown because that one genuinely is
ambiguous, and neither can earn absent. Publication is pinned by engine.test.ts, whose depth-1 raw
case now carries structural depths and asserts the cut really dropped both container and dimmer while
the fact still answers presented, and by ios-snapshot-runtime-presented-surface.test.ts, which fails
when the runtime seam drops the field.

@thymikee
thymikee added this pull request to stack #3580 October 13, 2026 12:50
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.20 MB 5.20 MB +1.8 kB
Package (unpacked) 5.20 MB 5.20 MB +1.8 kB
Package (download) 1.56 MB 1.56 MB +650 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.0 ms 27.1 ms +0.0 ms
CLI --help 76.4 ms 77.1 ms +0.6 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/capture-kit/src/ios-snapshot-engine/presented-surface.ts Outdated
@thymikee
thymikee force-pushed the feat/3565-presented-surface-rule branch 3 times, most recently from aaaafd1 to 4f570d9 Compare October 13, 2026 14:34
@thymikee
thymikee force-pushed the feat/3565-presented-surface-rule branch 2 times, most recently from ef18cc2 to 6fa19c6 Compare October 13, 2026 14:47
@thymikee
thymikee force-pushed the feat/3565-presented-surface-rule branch 2 times, most recently from 9f5a978 to e7929c3 Compare October 13, 2026 15:28
@thymikee
thymikee force-pushed the feat/3565-presented-surface-rule branch from e7929c3 to 9af006d Compare October 13, 2026 15:54
@thymikee

Copy link
Copy Markdown
Member Author

Re-cut the layer boundary to fix the failing Compatibility & Provenance run: this layer exported collectPresentedSurface with no production consumer until the next layer. The publication is now here, so the rule ships with its first reader. Moving it in put this layer at 1,242 gross lines against the 1,000 budget, so the Swift rule and its parity test moved to #3579, where the runner is the consumer. The stack's final tree is byte-identical to before the re-cut — no behavior changes, only which PR carries which file. Head is 9af006d47: check:production-exports, swift build, build, typecheck, the eager-closure suite (842) and capture-kit/kernel vitest all verified at this head, 870 gross lines.

@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the PR. Two things need to change before this can merge, at 9af006d.

The presentation vocabulary now has two owners. engine.ts:277 declares UITransitionView and UIDimmingView again, with its own exact-case dimming check. geometry-policy.ts:16-17,293-304 already has the same names and matches them through normalizeType. So a role spelled with a module prefix or different case can count as presentation evidence for the occlusion cut but not for presentedSurface, or the reverse, and the next vocabulary change must be made twice. The rule: one module owns the UIKit presentation vocabulary and its predicates, and every iOS reader of presentation structure goes through it. The eager-edge reason does not hold, because geometry.ts already imports geometry-policy.ts and the engine imports geometry.ts. Could collectPresentedSurface live in geometry-policy.ts, calling the existing isPresentationContainer and isPresentationDimmingView (exported), so no constant is re-declared and the "pairing" comments at engine.ts:265-275 go away? engine.ts would then keep only the publish call, and the vocabulary test can import the constants from there.

This head publishes presentedSurface on every simulator-ax-bridge snapshot result, and response-views.ts:38 exposes it. The body cites values from 719fab6 and says a re-run at 54a46e7 is in progress. Neither is this head, and the two capture flavors are not recorded separately. Please run the RN test app on the iPhone 17 simulator at this head, or at the stack top with git diff showing the trees are byte-identical. Record snapshot and snapshot -i JSON for each of these. Open automation sheet should show presentedSurface as presented in both. Home, Catalog and the pushed lab screen should show absent in both.

The thread on duplicated indexes from cubic-dev-ai (#3578 (comment)) is fixed at this head, because identifiesEachNodeOnce returns unknown before the walk and the fixture pins it, so you can resolve it.

Both Smoke Tests jobs are still queued, not failed. This was a read-only review, so I did not run the parity tests, check:production-exports or the eager-closure suite. I did not look at the Swift twin in #3579. Before merge, move the rule into geometry-policy.ts and attach the live output at this head.

@thymikee
thymikee force-pushed the feat/3565-presented-surface-rule branch from 9af006d to 777d9b7 Compare October 13, 2026 17:20
Add the rule that answers whether an iOS capture read a modal presentation over
the app's own content, the golden table that pins it, and the publication that
carries its answer, so the rule has a production consumer the moment it lands.

The rule reads a dimming view without its enablement, unlike the occlusion cut
that shares the same container class: a sheet resting at an undimmed detent keeps
a dimming view that takes no touches and is still presented. `absent` is the
strong answer and never the default, because a consumer arms a dismissal
confirmation on it. It requires that the producer stated the capture was not
capped, that every node carries the UIKit class name the rule reads, that each
node is numbered once so the parent links the walk follows name the node they
mean, and that the one content window under the app root holds exactly one
presentation container. Positive evidence — the dimming view a presentation owns,
an alert's own root class, or an element type that names a presentation — answers
`presented` from a single node, so a capped capture still reports what it read.
Producers that publish no class names, which is every XCTest runner tier, answer
`presented` or `unknown` and never `absent`.

The rule lives in `geometry-policy.ts` beside the occlusion cut rather than in
`engine.ts`, because that module already owns the UIKit presentation vocabulary
both readers consume and `geometry.ts` already imports it on the engine's eager
path, so the placement costs nothing and leaves one module to change when UIKit
does. Both readers now match class names through the same normalization, so a
producer that spells a class with a module prefix cannot reach one answer in the
occlusion cut and another here.

Evidence is matched on the node occurrence a walk stood on, not on the number it
carries, so a keyboard host's subtree cannot swallow an app window's own dimming
view; only `absent`, a claim about every window the producer read, requires
numbering that identifies each node once.

The publication is in this layer rather than one up because
`check:production-exports` refuses an exported rule with no production consumer,
and a layer that ships a decision nobody can read is not a layer that passes. The
Apple runner's Swift twin and its own consumer land in the next layer, where the
runner acquires trees; both languages read the one fixture published here.
@thymikee
thymikee force-pushed the feat/3565-presented-surface-rule branch from 777d9b7 to ecd8cb6 Compare October 13, 2026 18:23
@thymikee

Copy link
Copy Markdown
Member Author

The code now looks right at ecd8cb6, but the live run from the earlier review (9af006d, #3578 (comment)) is still missing. All 19 checks pass at this head.

The cubic-dev-ai thread on repeated numbers in positionsOutsideSystemWindows is fixed at this head, so please resolve it: #3578 (comment). No other open threads apply.

Fixed since 9af006d: the repeated-number exclusion now keys on node occurrence, identifiesEachNodeOnce blocks absent, and fixture cases at ios-presented-surface.json lines 292-323 pin both shapes.

Still open is the live run. This head publishes presentedSurface on every engine-classified iOS snapshot result. The PR body still cites c06d8ec and 777d9b7, and the changes since then moved the rule and switched class matching to normalized spelling. A live producer exercises exactly that spelling, so nothing yet shows that this commit gives presented on the open sheet and absent on Home, Catalog and the pushed lab screen. Please run the RN test app on the iPhone 17 simulator at ecd8cb6. You can also run it at the stack top, if git diff ecd8cb6 <top> -- packages/capture-kit packages/contracts contracts/fixtures shows empty. Record snapshot --json and snapshot -i --json. The open sheet must show presentedSurface presented in both captures, and Home, Catalog and the pushed lab screen must show absent in both.

I reviewed this read-only and did not run vitest, check:production-exports or the eager-closure suite. The Swift SnapshotPresentedSurface and its parity test live in #3579, so I could not check that Swift mirrors normalizeType as the new class-name note claims. I also did not verify that the stack top is byte-identical to this head.

Not blocking: the PR body still says the TypeScript rule lives in engine.ts for the ADR 0027 eager-edge reason and cites older evidence heads, while the ecd8cb6 commit message says geometry-policy.ts owns it, so you may want to update the body to match, or leave it.

Before merge, the live simulator run above needs to land in the PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant