Skip to content

Commit bf6abb2

Browse files
thymikeeclaude
andauthored
fix(snapshot): stop sibling-sized chrome containers from covering their own region (#2996) (#3097)
* fix(snapshot): stop sibling-sized chrome containers from covering their own region (#2996) A chrome container published with exactly the frame of a sibling reports that sibling's footprint instead of a surface drawn over it, but the occlusion pass read its box as covering evidence. `isFullViewportChromeContainer` only exempts a container sized to the viewport root, so a container that stops short of it condemned content on both sides. Captured live on an iPhone 18 Pro (iOS 27.0) through the shipped publication path (`buildSnapshotState`): a system app's bottom-bar container matching the 812pt content branch beside it marked 36 of that branch's nodes plus the navigation bar listed beside it; a JS-tab app's tab-bar badge container matching the platter content view beside it marked all 13 tab-bar nodes. Both now mark 0 and 2 respectively, the two survivors genuinely under the tab bar, and the Android IME path is untouched because only the tabbar/toolbar/navigationbar kinds qualify. Co-Authored-By: Claude Opus 4.8 <[email protected]> * refactor(snapshot): own the sibling-sized chrome rule in its own module Move the #2996 rule and its sibling index out of `snapshot-occlusion.ts` into `snapshot-sibling-sized-chrome.ts` with the matching test file, and single-source the chrome-kind question in `snapshot-chrome-kind.ts` so both occlusion exemptions read one classification. The new modules stay beside their caller: layering policy R73 owns `capture-kit/src/snapshot/` for the presentation runtime. * test(snapshot): split the sibling-sized-chrome family out of the occlusion tests Move the #2996 cases into `snapshot-sibling-sized-chrome.test.ts`, which drives the rule through `annotateCoveredSnapshotNodes` and pins each published iOS kind shape from one table. `snapshot-occlusion.test.ts` returns to its merge-base length, clearing the test-file tripwire. The rule stays inside `snapshot-occlusion.ts`: the eager-closure gate forbids a new module in the closure of a published entry surface, and that file is one. * docs(snapshot): name the sibling-frame limit of the chrome exemption --------- Co-authored-by: Claude Opus 4.8 <[email protected]>
1 parent f7d7f38 commit bf6abb2

2 files changed

Lines changed: 494 additions & 6 deletions

File tree

‎packages/capture-kit/src/snapshot-occlusion.ts‎

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,30 @@ const SEMANTIC_TOUCH_KIND_FRAGMENTS = [
5151
type OcclusionScan = {
5252
nodes: readonly RawSnapshotNode[];
5353
byIndex: Map<number, RawSnapshotNode>;
54+
neighbourhood: NeighbourhoodIndex;
5455
overlayPositions: number[];
5556
coverCache: Map<number, RawSnapshotNode | null>;
5657
};
5758

59+
/** One-tree context for classifying a node's neighbours without rescanning the whole snapshot. */
60+
type NeighbourhoodIndex = {
61+
byIndex: Map<number, RawSnapshotNode>;
62+
childrenByParent: Map<number, RawSnapshotNode[]>;
63+
};
64+
65+
function collectNeighbourhoodIndex(nodes: readonly RawSnapshotNode[]): NeighbourhoodIndex {
66+
const byIndex = new Map<number, RawSnapshotNode>();
67+
const childrenByParent = new Map<number, RawSnapshotNode[]>();
68+
for (const node of nodes) {
69+
byIndex.set(node.index, node);
70+
if (typeof node.parentIndex !== 'number') continue;
71+
const siblings = childrenByParent.get(node.parentIndex);
72+
if (siblings) siblings.push(node);
73+
else childrenByParent.set(node.parentIndex, [node]);
74+
}
75+
return { byIndex, childrenByParent };
76+
}
77+
5878
export type SnapshotOcclusionOptions = {
5979
/** Backend-declared overlay roots that use the shared geometric scan (for example Android IME). */
6080
isAdditionalOverlayNode?: (node: RawSnapshotNode) => boolean;
@@ -71,17 +91,19 @@ export function annotateCoveredSnapshotNodes(
7191
): RawSnapshotNode[] {
7292
if (nodes.length < 2) return nodes;
7393

74-
const byIndex = new Map(nodes.map((node) => [node.index, node]));
94+
const neighbourhood = collectNeighbourhoodIndex(nodes);
95+
const byIndex = neighbourhood.byIndex;
7596
const scan: OcclusionScan = {
7697
nodes,
7798
byIndex,
99+
neighbourhood,
78100
// Mutation-lane note: replacing the `[]` (non-overlay) branch with a
79101
// non-empty placeholder would only add extra, non-numeric entries to
80102
// this array; every consumer treats it as a plain array of positions to
81103
// compare/index with, so a stray non-numeric entry is inert (fails the
82104
// comparison, indexes to `undefined`) rather than observable.
83105
overlayPositions: nodes.flatMap((node, position) =>
84-
isOverlayLikeNode(node, byIndex, options) ? [position] : [],
106+
isOverlayLikeNode(node, neighbourhood, options) ? [position] : [],
85107
),
86108
coverCache: new Map(),
87109
};
@@ -221,7 +243,7 @@ function visibleCoverRect(
221243
options: SnapshotOcclusionOptions,
222244
): Rect | null {
223245
const candidate = scan.nodes[candidatePosition];
224-
if (!candidate || !isOverlayLikeNode(candidate, scan.byIndex, options)) return null;
246+
if (!candidate || !isOverlayLikeNode(candidate, scan.neighbourhood, options)) return null;
225247
if (areRelatedSnapshotNodes(target, candidate, scan.byIndex)) return null;
226248
const candidateRect = positiveRect(candidate.rect);
227249
if (!candidateRect || areRectsApproximatelyEqual(targetRect, candidateRect)) return null;
@@ -246,17 +268,53 @@ function isCandidateTouchNode(node: RawSnapshotNode): boolean {
246268
// node.
247269
function isOverlayLikeNode(
248270
node: RawSnapshotNode,
249-
byIndex: Map<number, RawSnapshotNode>,
271+
neighbourhood: NeighbourhoodIndex,
250272
options: SnapshotOcclusionOptions,
251273
): boolean {
252274
if (!positiveRect(node.rect)) return false;
253275
if (isViewportRootNode(node)) return false;
254-
if (isFullViewportChromeContainer(node, byIndex)) return false;
276+
if (isFullViewportChromeContainer(node, neighbourhood.byIndex)) return false;
277+
if (isSiblingSizedChromeContainer(node, neighbourhood)) return false;
255278
// This is a presentation-order heuristic: only known floating UI chrome should cover
256279
// later targets. Generic hittable containers can appear later without being visually on top.
257280
return (
258281
nodeKindIncludesAny(node, OVERLAY_KIND_FRAGMENTS) ||
259-
isAdditionalOverlayRootNode(node, byIndex, options)
282+
isAdditionalOverlayRootNode(node, neighbourhood.byIndex, options)
283+
);
284+
}
285+
286+
/**
287+
* Exempt a viewport-chrome container whose frame equals a listed sibling's. UIKit sizes a bar
288+
* container to the branch it presents over, so in the captured shapes a sibling-matching frame is
289+
* that branch's footprint, not a surface drawn on top of it; what the container really draws is its
290+
* descendants, each with its own rect the scan judges on its own (#2996).
291+
*
292+
* Two captures carry the shape: a system app's bar container matching the 812pt content branch beside
293+
* it marked that branch's 36 nodes plus the navigation bar listed with it, and a tab-bar badge
294+
* container matching the platter content view beside it marked every tab. `isFullViewportChromeContainer`
295+
* reaches the same verdict only against the viewport root, which such a container stops short of.
296+
*
297+
* Accepted limit: a snapshot publishes geometry and kind only, so the predicate cannot tell a
298+
* pass-through host from an opaque bar of the same frame, and chrome siblings that share one frame
299+
* excuse each other. Both captures show the match only where content was being refused, and the kind
300+
* gate keeps a `dialog`/`sheet`/`alert` sized to the content around it covering.
301+
*/
302+
// Mutation-lane note: the `typeof parentIndex` and `!rect` guards below are provably
303+
// redundant — with no parent index the sibling lookup finds nothing and answers "not
304+
// sibling-sized" on its own, and a rect-less node fails `areRectsApproximatelyEqual`,
305+
// which is the same answer the guard gives. They state the preconditions rather than
306+
// enforce them.
307+
function isSiblingSizedChromeContainer(
308+
node: RawSnapshotNode,
309+
neighbourhood: NeighbourhoodIndex,
310+
): boolean {
311+
if (!nodeKindIncludesAny(node, VIEWPORT_CHROME_KIND_FRAGMENTS)) return false;
312+
if (typeof node.parentIndex !== 'number') return false;
313+
const rect = positiveRect(node.rect);
314+
if (!rect) return false;
315+
const siblings = neighbourhood.childrenByParent.get(node.parentIndex) ?? [];
316+
return siblings.some(
317+
(sibling) => sibling.index !== node.index && areRectsApproximatelyEqual(rect, sibling.rect),
260318
);
261319
}
262320

0 commit comments

Comments
 (0)