Skip to content

Commit b31fb0b

Browse files
committed
docs: slim scroll clip authority ADR
1 parent e35d0d4 commit b31fb0b

1 file changed

Lines changed: 50 additions & 177 deletions

File tree

Lines changed: 50 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,190 +1,63 @@
1-
# ADR 0026: Scroll Clip Authority — Ownership Is the Parent Edge
1+
# ADR 0026: Scroll clip authority
22

33
## Status
44

55
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.
9432

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.
9736

98-
- A collapsed row.
99-
- A duplicate label.
100-
- Wrapper content.
101-
- A system indicator.
37+
## Eligibility and platform scope
10238

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`.
10841

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.
11448

115-
## Refuted alternatives
49+
## Rejected alternatives
11650

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.
15557

15658
## Consequences and open evidence
15759

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

Comments
 (0)