|
1 | | -# ADR 0026: Scroll Clip Authority — Ownership Is the Parent Edge |
| 1 | +# ADR 0026: Scroll clip authority |
2 | 2 |
|
3 | 3 | ## Status |
4 | 4 |
|
5 | 5 | Accepted (2026-09-22). The ownership decision is shipped. |
6 | | -#2758 reads indicator ownership from the parent edge and traverses only same-frame ancestors. |
7 | | -The #2754 step 3 survey justified this tolerance with real captures. |
8 | | -#2759 pairs the eligibility lists and decides macOS `ScrollArea` behavior. |
9 | | -Two #2754 steps remain open, outside the shipped decision: |
10 | | - |
11 | | -- The runner-stage differential arm. |
12 | | -- The typed ejection disposition in the "every removed source" rule below. |
13 | | - |
14 | | -This ADR refines ADR 0004's fact/interpretation boundary for scroll geometry. |
15 | | -It does not supersede ADR 0004. |
16 | | - |
17 | | -An interactive iOS list snapshot lost all rows after a row with selectable text. |
18 | | -A `UITextView` is a UIScrollView. |
19 | | -XCTest publishes its scroll indicator inside the text. |
20 | | -Presentation attributed that indicator to the surrounding list and reduced the visible list band to one 22 px line. |
21 | | -It clipped every lower row from `snapshot -i` (#2214 in 0.21.0). |
22 | | -#2740 patched this one type. |
23 | | -The cause was an **inferred** owner from traversal beyond the indicator's parent, not an unrecognized type. |
24 | | -That inference determined output membership. |
25 | | - |
26 | | -## Rules at a glance |
27 | | - |
28 | | -| Situation | Behavior | |
29 | | -| --- | --- | |
30 | | -| Indicator ownership | Its **parent** owns it when the parent is a scroll type. Ownership also passes through ancestors with their own parent's **exact frame**. These are transparent wrappers around one region. A scroll-typed node with the indicator label describes itself and owns nothing. | |
31 | | -| An indicator whose nearest scroll-typed ancestor is reached only across a frame change, for example a smaller `TextView`/`WebView`/cell host | The indicator owns nothing. It gets no band and no clip. The smaller `TextView`/`WebView`/cell host retains its scrollability. A frame change ends traversal, regardless of label or type. | |
32 | | -| Visible band | Presentation derives it from the owner's frame and indicator track. A producer never reports it. | |
33 | | -| Removal **because a scroll band hides a node** | Require the clip fold's result or a band owned by the indicator's parent. Inferred ownership cannot authorize removal. | |
34 | | -| Removal of a node by semantic delegation, for example a collapsed row, a duplicate label, or wrapper content | This is compaction, outside this ADR. It retains its own authority. | |
35 | | -| Evidence weaker than a parent edge | Emit only directional hints, `hiddenContentAbove` / `hiddenContentBelow`. Do not change membership. | |
36 | | -| Every removed source | Must have a typed disposition. This is the open #2754 step 5 contract, not shipped code. It is internal evidence, never wire vocabulary. | |
37 | | - |
38 | | -## Contracts |
39 | | - |
40 | | -**Read ownership from structure. Do not infer it.** |
41 | | -A scroll view's indicators are its children. |
42 | | -The parent edge is therefore the producer's ownership claim. |
43 | | -No new contract field, cross-language ownership table, or capture-local index remap is necessary. |
44 | | -The edge survives until presentation at both input stages because every scroll host that iOS can emit is regular-eligible. |
45 | | -Projection therefore re-parents indicators only to that host. |
46 | | -These two lists encode the same fact and must remain synchronized: |
47 | | - |
48 | | -- `REGULAR_ELIGIBLE_TYPES` in `ios-snapshot-engine/projection.ts`. |
49 | | -- `eligibleInteractiveTypes` in `SnapshotPresentationProjection.swift`. |
50 | | - |
51 | | -Both contain these types: |
52 | | - |
53 | | -- `Cell` |
54 | | -- `CollectionView` |
55 | | -- `ScrollView` |
56 | | -- `Table` |
57 | | -- `TextView` |
58 | | -- `WebView` |
59 | | - |
60 | | -**Same-frame pass-through preserves structural ownership.** |
61 | | -The producer states that two nodes have the same rectangle. |
62 | | -This is a structural fact, not a label or type inference. |
63 | | -Same-frame pass-through is necessary because a `WKWebView` page indicator is published under `WebView` wrappers that exactly fill its `ScrollView`. |
64 | | -#1784/#1797 and real #2754 step 3 captures establish this shape. |
65 | | -Strict parent-edge ownership left this region without a band and exposed content under the toolbar. |
66 | | -Traversal stops at the first frame change. |
67 | | -A smaller host, such as a `WebView` row inside a list, describes a different scroll region and still owns nothing. |
68 | | -Same-frame pass-through must not restore #2214's failure. |
69 | | - |
70 | | -**The eligibility claim applies only to iOS.** |
71 | | -`isScrollableSnapshotType` accepts `scrollarea`, but neither eligible set contains it. |
72 | | -The iOS runner never emits it. |
73 | | -It originates in the macOS helper's `AXScrollArea` mapping. |
74 | | -Those trees reach these rules through `snapshot-desktop-surface.ts` → `ios-snapshot-runtime.ts` → `publishIosSnapshot`. |
75 | | -On those trees, eligibility can remove a `ScrollArea` host while its children re-parent beyond it. |
76 | | -Then a parent-edge lookup finds no owner. |
77 | | -Reuse on macOS therefore required a separate eligibility decision. |
78 | | -#2759 established these cases without admitting the type for iOS: |
79 | | - |
80 | | -- `scrollarea` remains outside both eligible sets. |
81 | | -- A content-bearing `ScrollArea` survives eligibility and owns its band. |
82 | | -- A label-less `ScrollArea` disappears and leaves its indicator without an owner. |
83 | | - |
84 | | -The last case safely under-clips instead of incorrectly clipping the enclosing list. |
85 | | -`eligibility-parity.test.ts` proves these cases. |
86 | | - |
87 | | -**Why the band is necessary.** |
88 | | -XCTest reports a scroll frame across bars and safe areas, beyond the visible track. |
89 | | -In the pinned Settings tree, `CollectionView` spans the complete screen, 0–874, but its indicator track spans 116–812. |
90 | | -`runner-presentation.test.ts` records this example. |
91 | | -The frame alone cannot express visibility. |
92 | | -Removal of indicator clipping would expose content under system chrome. |
93 | | -The band is necessary; its owner was the disputed fact. |
| 6 | +The runner-stage differential arm and the typed ejection disposition in #2754 remain open. |
| 7 | +This record refines ADR 0004's fact and interpretation boundary for scroll geometry; it does not supersede ADR 0004. |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +A scroll indicator attributed to an enclosing list instead of its smaller text host clipped the list's lower rows (#2214). |
| 12 | +Walking past the indicator's parent had inferred ownership across a frame change. |
| 13 | +A scroll frame can extend under system chrome, so the frame alone cannot express the visible band. |
| 14 | + |
| 15 | +## Decision |
| 16 | + |
| 17 | +Read indicator ownership from structure, not inference. |
| 18 | +The indicator's parent owns it when the parent is a scroll type. |
| 19 | +Ownership also passes through ancestors with their own parent's exact frame, which are transparent wrappers around one region. |
| 20 | +Traversal stops at the first frame change, regardless of label or type. |
| 21 | +An indicator whose nearest scroll-typed ancestor is reached only across a frame change owns nothing and gets no band or clip. |
| 22 | +The smaller host retains its scrollability. |
| 23 | +A scroll-typed node with the indicator label describes itself and owns nothing. |
| 24 | + |
| 25 | +Presentation derives the visible band from the owner's frame and indicator track; a producer never reports the band. |
| 26 | +Removal because a scroll band hides a node requires the clip fold's result or a band owned by the indicator's parent. |
| 27 | +Inferred ownership cannot authorize removal. |
| 28 | +Evidence weaker than a parent edge emits only `hiddenContentAbove` or `hiddenContentBelow` directional hints and does not change membership. |
| 29 | +Semantic delegation, such as a collapsed row, duplicate label, or wrapper content, is compaction outside this decision and retains its own authority. |
| 30 | +The decision also covers the clip fold's inclusion decision and the three keyboard-band visibility sites. |
| 31 | +A weak signal can reach only the scroll-band site. |
94 | 32 |
|
95 | | -**Visibility ejection requires evidence. Each ejection must be recorded.** |
96 | | -Most of the engine's 23 removal sites delegate semantics or remove decoration, for example: |
| 33 | +Every source index must end presented with its representatives or removed with a typed disposition. |
| 34 | +This is the open #2754 step 5 contract, not shipped code; it is internal evidence, never wire vocabulary. |
| 35 | +The existing source-to-representatives ledger is the shape to extend, because a second ledger would be a second source of truth. |
97 | 36 |
|
98 | | -- A collapsed row. |
99 | | -- A duplicate label. |
100 | | -- Wrapper content. |
101 | | -- A system indicator. |
| 37 | +## Eligibility and platform scope |
102 | 38 |
|
103 | | -This ADR does not change their authority. |
104 | | -It governs four visibility-driven sites: one scroll-band site and three keyboard-band sites. |
105 | | -It also covers the clip fold's inclusion decision in `geometry-policy.ts`. |
106 | | -A weak signal can reach only the scroll-band site. |
107 | | -If nothing is the indicator's parent, directional hints are the safe outlet for that signal. |
| 39 | +On iOS, every scroll host the producer can emit is regular-eligible, so projection re-parents indicators only to that host. |
| 40 | +The TypeScript and Swift eligibility lists must remain synchronized for `Cell`, `CollectionView`, `ScrollView`, `Table`, `TextView`, and `WebView`. |
108 | 41 |
|
109 | | -Each source index must end presented with its representatives or removed with a typed reason. |
110 | | -This rule makes the ledger complete by construction, without a gate. |
111 | | -#2754 step 5 still owes the removal reason. |
112 | | -Currently, `presentedIndexesBySourceIndex` in `ios-snapshot-engine/semantic-index.ts` records representatives without removal reasons. |
113 | | -That ledger is the shape to extend. A second parallel ledger would be a second source of truth. |
| 42 | +The iOS runner does not emit `scrollarea`; the macOS helper maps `AXScrollArea` to it. |
| 43 | +macOS trees also use these presentation rules. |
| 44 | +`scrollarea` remains outside both eligible lists. |
| 45 | +A content-bearing `ScrollArea` survives eligibility and owns its band. |
| 46 | +A label-less `ScrollArea` disappears and leaves its indicator without an owner. |
| 47 | +That case under-clips instead of incorrectly clipping the enclosing list. |
114 | 48 |
|
115 | | -## Refuted alternatives |
| 49 | +## Rejected alternatives |
116 | 50 |
|
117 | | -- **A producer-reported ownership field on `IosRunnerPresentation`.** |
118 | | - The parent edge already supplies the fact. |
119 | | - A field would add validation, capture-local indexing, and remap hazards, and give no new information. |
120 | | -- **Producer-computed visible bands.** |
121 | | - This moves interpretation back across the process boundary. |
122 | | - ADR 0004 removed interpretation from the producer side of that boundary. |
123 | | - Bands remain host-side. |
124 | | -- **An owner found by walking ancestors.** |
125 | | - That walk caused the incident. |
126 | | - Exclusion of only known scroll types, as in #2740, leaves the next scroll-shaped host, for example a `WebView`, a map view, or a paged cell, to reopen the incident. |
127 | | -- **One universal scroll-container set or a matrix of ownership/clip/termination booleans.** |
128 | | - Five sites currently answer related questions: |
129 | | - - `ios-snapshot-engine/tree.ts` |
130 | | - - `geometry-policy.ts` |
131 | | - - `invariants.ts` |
132 | | - - `SnapshotVisibilityFold.swift` |
133 | | - - `RunnerTests+Snapshot.swift` |
134 | | - Their contents differ. |
135 | | - Only `tree.ts` accepts macOS `scrollarea`, which the iOS runner never emits. |
136 | | - Before you reshape any of these sites, characterize each site. |
137 | | - Parent-edge ownership needs only the existing `isScrollableSnapshotType`. |
138 | | -- **A rule graph, effect system, or two-API reshape/eject split.** |
139 | | - The existing code does not justify this structure. |
140 | | - Nineteen of 23 `suppressNode` sites legitimately eject nodes. |
141 | | - Membership access for "one clip rule" would restore the convention that the API claims to enforce. |
142 | | - The scroll rule and navigation-bar affordance rule in `transitions.ts` already change rectangles and eject nodes in one pass. |
143 | | - `noise-overlay` demonstrates a valid split: replacements-only presentation and a separate suppression pass with ejection authority. |
144 | | - A reshape API that still accepts `rect` would permit the original faulty change. |
145 | | - First inventory sites in #2754. |
146 | | - Redesign only what that inventory justifies. |
147 | | -- **A shrinkage guard instead of the differential.** |
148 | | - A threshold would either reject legitimate compaction or permit a 74 → 20 collapse. |
149 | | - Settings legitimately removes rows under system chrome. |
150 | | - The differential already asserts interactive ⊆ regular ⊆ raw. |
151 | | - It lacks runner-stage coverage. |
152 | | - `assertProjectionSubsets` in `ios-snapshot-engine/engine-properties.test.ts` constructs only acquired inputs. |
153 | | - The Swift differential omits interactive cases. |
154 | | - Extension to `stage: 'presented'` makes future shrinkage visible. |
| 51 | +- A producer ownership field: the parent edge already supplies the fact, and a field adds validation and index-remapping hazards. |
| 52 | +- Producer-computed bands: this moves interpretation across the producer boundary established by ADR 0004. |
| 53 | +- An ancestor walk across frame changes: it caused the original failure; excluding known types alone leaves other smaller hosts exposed to the same error. |
| 54 | +- One universal scroll-container set or an ownership and clip matrix: the sites answer different questions, and only the host-side scroll type predicate accepts macOS `scrollarea`. Characterize each site before reshaping it. |
| 55 | +- A rule graph, effect system, or two-API reshape/eject split: existing rules can change rectangles and eject in one pass, while a replacements-only presentation with a separate suppression pass is also valid. Inventory ejection sites in #2754 first; redesign only what that inventory justifies. |
| 56 | +- A shrinkage guard instead of the differential: a threshold can reject legitimate compaction or permit a collapse. The differential asserts interactive ⊆ regular ⊆ raw, but lacks runner-stage coverage. Extending it to `stage: 'presented'` makes future shrinkage visible. |
155 | 57 |
|
156 | 58 | ## Consequences and open evidence |
157 | 59 |
|
158 | | -- **Under-clipping was the open risk. Same-frame pass-through resolves the real instance.** |
159 | | - Strict parent-edge ownership left a `WKWebView` page without a band because `WebView` wrappers contained the indicator. |
160 | | - It exposed content under the toolbar (#1797, with adjacent #1784). |
161 | | - #2754 step 3 surveyed Settings, Settings › Privacy & Security, and a Safari `WKWebView` page. |
162 | | - The captures contained 25 indicators, 18 under non-scroll parents. |
163 | | - The survey found exactly this shape on the web surface. |
164 | | - Same-frame pass-through reproduces `main` clipping on all three captures. |
165 | | - Smaller-host cases from #2214 still own nothing. |
166 | | - A non-scroll wrapper with a different frame would still under-clip. |
167 | | - No captured iOS tree has that shape. |
168 | | -- **Real data confirms the `WebView` shape.** |
169 | | - A Safari `snapshot -i --raw` capture shows the `WKWebView` indicator under `Other` → `WebView` → `WebView`. |
170 | | - These ancestors exactly fill the `ScrollView`. |
171 | | - `runner-presentation.test.ts` reduces that live tree into a fixture. |
172 | | -- **A scroll-typed indicator owns nothing.** |
173 | | - #2754 step 1 removed self-ownership. |
174 | | - A self-band is ineffective because indicator and container share a rectangle, which `deriveScrollableViewportRect` refuses. |
175 | | - A parent band would incorrectly clip a sibling list, reproducing #2214 in the opposite direction. |
176 | | - Thus, `findScrollIndicatorContainer` returns null for an indicator node that is itself a scroll type. |
177 | | - `runner-presentation.test.ts` pins that a scroll host labelled as an indicator leaves the parent list's band intact. |
178 | | -- **macOS already consumes these rules.** |
179 | | - Desktop capture uses `snapshot-desktop-surface.ts` → `ios-snapshot-runtime.ts` → `publishIosSnapshot`. |
180 | | - #2759 decided the `ScrollArea` cases described above. |
181 | | - Content-bearing hosts survive and own their bands. |
182 | | - Label-less hosts disappear and leave ownerless indicators, which under-clip rather than incorrectly clip the list. |
183 | | - `eligibility-parity.test.ts` pins both outcomes and the TypeScript/Swift eligibility pair. |
184 | | -- **The ejection inventory precedes API changes.** |
185 | | - There are 23 suppression sites across ten rules. |
186 | | - Two, `scroll` and `transitions`, already change other nodes' rectangles and eject in the same pass. |
187 | | - The proposed API split claimed to prohibit this existing combination. |
188 | | -- **The reporting screen's 74 → 67 node change remains unexplained.** |
189 | | - No captured artifact exists. |
190 | | - Neither number is an acceptance baseline. |
| 60 | +Same-frame pass-through preserves a `WKWebView` band's ownership through same-frame wrappers while smaller hosts still own nothing. |
| 61 | +A non-scroll wrapper with a different frame can still under-clip; no captured iOS tree had that shape in the #2754 survey. |
| 62 | +The ejection inventory precedes API changes. |
| 63 | +The reporting screen's 74 → 67 node change remains unexplained; no captured artifact exists, and neither number is an acceptance baseline. |
0 commit comments