Skip to content

Commit 215229b

Browse files
authored
docs(adr): scroll clip authority — ownership is the parent edge (#2755)
* docs(adr): record scroll clip authority for iOS snapshot presentation An interactive snapshot of a list dropped every row after a row holding selectable text: a UITextView is a UIScrollView, XCTest publishes its indicator inside the text, and the presentation attributed that indicator to the surrounding list, so the list's visible band became one line of text and everything below it was clipped (#2214, fixed by #2740). The type-specific fix closed one instance. ADR 0026 records the rule that keeps the class out: indicator ownership is a fact the producer reports, the visible band stays host interpretation, and an inferred attribution gets hint authority only — it cannot eject a node. Reshape and eject become separate rule APIs, scroll capability becomes a matrix instead of one boolean answered five different ways, and every ejection carries a typed reason the differential can check. Design contract: #2754. * docs(adr): read scroll indicator ownership from the parent edge Review found the first draft built eight contracts on top of a bug that a parent-edge lookup removes. UIKit publishes an indicator inside its own scroll view and projection preserves that edge, because every scroll host is regular-eligible in both languages; the ancestor walk past the parent is what misattributed the text view's indicator in the first place. Reading the parent instead retires the proposed IosRunnerPresentation field, its capture-local indexing and remapping, the ownership capability matrix, and the two-tree split. Also corrects the band's premise: the pinned Settings tree has the CollectionView frame at 0-874 with an indicator track at 116-812, so the frame spans the bars and safe area rather than reporting a content extent. Drops the two-API reshape/eject split, which does not hold against ~23 suppression sites across ten rules, and against a reshape surface that still accepts rect. Keeps the decision: ownership is read, ejection needs evidence, every removal carries a disposition. Measured while revising: the full unit suite passes with parent-edge ownership, and a synthetic WebView row keeps rows that main drops. * docs(adr): name scrollarea as the iOS-scoped exception Review asked the eligibility claim be scoped, since isScrollableSnapshotType also accepts scrollarea and neither eligible set contains it. Verified the scope is real rather than hypothetical: macOS desktop capture reaches these rules through snapshot-desktop-surface -> ios-snapshot-runtime -> publishIosSnapshot, and its helper emits ScrollArea from AXScrollArea while the iOS runner never does. So "every scroll host is regular-eligible" is written as an iOS claim, with a ScrollArea decision owed on that surface before the parent-edge rule is reused there. Also names the divergence left vague before: only tree.ts accepts scrollarea. Counts made exact while there: 23 suppression sites across ten rules, and scroll and noise-overlay already patch rects while ejecting. * docs(adr): scope visibility ejection and name the real rect-rewriting pair The removal row read as if every ejection needed a clip-fold result or a parent-edge owner. Nineteen of the 23 sites delegate semantics or drop decoration and keep their own authority, so the row and the paragraph now name the four visibility-driven sites the ADR actually governs, plus the clip fold's inclusion decision, and a second row states that compaction is out of scope. Corrects the rect-rewriting pair: transitions rewrites a field's rect and ejects the title and image in one pass, while noise-overlay patches rects in a presentation function that receives replacements only and ejects from a separate suppression function. That module already has the split the rejected API proposed, so it is cited as the precedent instead. Names why the runtime guard was rejected: a threshold either fires on ordinary compaction or is loose enough to pass a 74 to 20 collapse. Cites #1797 for the fold's leak class with #1784 adjacent instead of claiming one class, drops the drafting narrative from the refuted field, records the unpinned self branch, and notes the table's own self case.
1 parent b566eb4 commit 215229b

2 files changed

Lines changed: 115 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# ADR 0026: Scroll Clip Authority — Ownership Is the Parent Edge
2+
3+
## Status
4+
5+
Proposed (2026-09-22). Refines ADR 0004's fact/interpretation boundary for scroll geometry; does not
6+
supersede it. Implementation contract: #2754.
7+
8+
An interactive iOS snapshot of a list dropped every row after a row holding selectable text. A
9+
`UITextView` is a UIScrollView and XCTest publishes its scroll indicator inside the text, so the
10+
indicator was attributed to the surrounding list, the list's visible band became one 22 px line, and
11+
everything below it was clipped out of `snapshot -i` (#2214 in 0.21.0, patched for that one type by
12+
#2740). The root cause is not the unrecognised type — it is that presentation **inferred** an owner by
13+
walking up past the indicator's parent, and that guess decided which nodes existed in the output.
14+
15+
## Rules at a glance
16+
17+
| Situation | Behavior |
18+
| --- | --- |
19+
| Which scroll view an indicator reports on | Its **parent**, when that parent is a scroll type (or the scroll host itself when it carries the label — open, below). UIKit publishes an indicator inside its own scroll view, and the producer's tree already says so |
20+
| An indicator whose parent is not a scroll type — `TextView`, `WebView`, a cell | Owns nothing. No band, no clip, and the host's own scrollability stays with that host |
21+
| The visible band | Derived by presentation from the owner's frame and the indicator's track. Never reported by a producer |
22+
| Removing a node **because a scroll band hides it** | Requires evidence: the clip fold's result, or a band whose owner is the indicator's parent. A guess about ownership may not do it |
23+
| Removing a node by semantic delegation — a collapsed row, a duplicate label, a wrapper's content | Out of scope here. That is compaction, and it keeps its own authority |
24+
| Weaker evidence than a parent edge | Directional hints (`hiddenContentAbove` / `hiddenContentBelow`) and nothing that changes membership |
25+
| Every removed source | Ends with a typed disposition. Internal evidence, never wire vocabulary |
26+
27+
## Contracts
28+
29+
**Ownership is read, not inferred.** A scroll view's indicators arrive as its children, so the parent
30+
edge is the producer's own claim and no new contract field, cross-language ownership table, or
31+
capture-local index remapping is needed. That edge survives to presentation on both input stages
32+
because every scroll host iOS can emit is regular-eligible, so projection re-parents an indicator only to
33+
that host: `REGULAR_ELIGIBLE_TYPES` in `ios-snapshot-engine/projection.ts` and `eligibleInteractiveTypes`
34+
in `SnapshotPresentationProjection.swift` both carry `Cell`, `CollectionView`, `ScrollView`, `Table`,
35+
`TextView`, and `WebView`. Those two lists are one fact in two languages and must stay in step.
36+
37+
That eligibility claim is scoped to iOS, and `scrollarea` is why. `isScrollableSnapshotType` accepts it,
38+
neither eligible set contains it, and the iOS runner never emits it — it originates in the macOS helper's
39+
`AXScrollArea` mapping. That helper's trees do reach these rules, through `snapshot-desktop-surface.ts`
40+
→ `ios-snapshot-runtime.ts` → `publishIosSnapshot`, so on that surface a `ScrollArea` host can be dropped
41+
by eligibility while its children re-parent past it, which leaves a parent-edge lookup with no owner.
42+
Reusing this rule for macOS therefore needs a `ScrollArea` eligibility decision made there, not carried
43+
over from the iOS claim above.
44+
45+
**Why the band exists at all.** XCTest reports a scroll view's frame spanning the bars and the safe
46+
area, not the visible track. In the pinned Settings tree the `CollectionView` frame is the whole screen,
47+
0–874, while its indicator track is 116–812 (`runner-presentation.test.ts`). The frame alone therefore
48+
cannot express visibility, and deleting indicator clipping would return content that is scrolled under
49+
the chrome. The band is necessary; only *who owns it* was ever in question.
50+
51+
**Visibility ejection needs evidence, and every ejection is recorded.** Most of the engine's 23 removal
52+
sites delegate semantics or drop decoration — a collapsed row, a duplicate label, a wrapper's content, a
53+
system indicator — and this ADR does not touch their authority. It governs the four visibility-driven
54+
sites among them, the scroll band's one and the keyboard band's three, alongside the clip fold's own
55+
inclusion decision in `geometry-policy.ts`. Of those, the scroll band is the one a weak signal can reach,
56+
and directional hints are its safe outlet when nothing is the indicator's parent. Every source index ends
57+
either presented, with its representatives, or removed with a typed reason, which makes the ledger complete
58+
by construction rather than gated. The existing `presentedIndexesBySourceIndex` in
59+
`ios-snapshot-engine/semantic-index.ts` is the shape to extend; a second parallel ledger would be a second
60+
source of truth.
61+
62+
## Refuted alternatives
63+
64+
- **A producer-reported ownership field on `IosRunnerPresentation`.** The parent edge already carries the
65+
fact, so a field would add validation, capture-local indexing, and remapping hazards of its own for
66+
nothing.
67+
- **Producer-computed visible bands.** Moves interpretation back across the process boundary ADR 0004
68+
removed it from. The band stays host-side.
69+
- **An owner found by walking ancestors.** That walk is the incident. Skipping only the types known to
70+
scroll — #2740's fix — leaves the next scroll-shaped host (`WebView`, a map view, a paged cell) to
71+
reopen it.
72+
- **One universal "is a scroll container" set, or a capability matrix of ownership/clip/termination
73+
booleans.** Five sites answer variants of this question today (`ios-snapshot-engine/tree.ts`,
74+
`geometry-policy.ts`, `invariants.ts`, `SnapshotVisibilityFold.swift`, `RunnerTests+Snapshot.swift`)
75+
and their contents already differ: only `tree.ts` accepts `scrollarea`, the type the macOS helper emits
76+
and the iOS runner never does. Characterise each site before reshaping any of them; with parent-edge
77+
ownership the shape this rule needs is the existing `isScrollableSnapshotType`.
78+
- **A rule-graph or effect system, and a two-API reshape/eject split.** Rejected on the shape of the
79+
code. Nineteen of the 23 `suppressNode` sites legitimately eject, so handing a membership API to "one
80+
clip rule" restores the convention it claims to enforce. Two functions already rewrite rects *and* eject
81+
in one pass — the scroll rule and the navigation-bar affordance rule in `transitions.ts` — while
82+
`noise-overlay` shows the split done properly: its presentation pass receives replacements only and a
83+
separate suppression pass receives the ejecting surface. And a reshape surface that still accepts `rect`
84+
would permit the exact rewrite that caused this incident. Inventory the sites in #2754 first; redesign
85+
only what the inventory justifies.
86+
- **A runtime guard on shrinkage instead of the differential.** A threshold would either fire on ordinary
87+
compaction — the Settings tree legitimately drops rows under the chrome — or sit loose enough to wave
88+
through a 74 → 20 collapse. The differential already asserts interactive ⊆ regular ⊆ raw; what it lacks
89+
is coverage of the runner stage, since `assertProjectionSubsets`
90+
(`ios-snapshot-engine/properties.test.ts`) builds acquired inputs only and the Swift differential drops
91+
interactive cases. Extending it to `stage: 'presented'` is what makes any future shrinkage visible.
92+
93+
## Consequences and open evidence
94+
95+
- **Under-clipping is the new risk.** Dropping the walk means a tree that places an indicator under a
96+
labelled wrapper rather than directly under its scroll view gets no band, so content scrolled under the
97+
chrome survives in the output. That is a leak of a different kind from the one the clip fold owns
98+
(#1797, with #1784 adjacent on private-AX projection); it is the band's own job, and nothing else covers
99+
it. Measured so far: the full unit suite passes with parent-edge ownership, and a synthetic `WebView` row
100+
keeps rows that `main` drops. Real captured trees must be surveyed for that wrapper shape before landing.
101+
- **The `WebView` instance is synthetic.** The mechanism is confirmed in a hand-built tree; that XCTest
102+
publishes a `WKWebView`'s indicator the same way is unverified.
103+
- **The self case is unpinned.** The rule also treats a scroll-typed node carrying an indicator label as
104+
its own owner. No test exercises that branch — parent-only ownership without it passes the full suite —
105+
so #2754 step 1 keeps it with a case or deletes it deliberately, rather than inheriting it.
106+
- **macOS is already a second consumer of these rules.** Desktop capture runs the engine through
107+
`snapshot-desktop-surface.ts` → `ios-snapshot-runtime.ts` → `publishIosSnapshot`, and its trees carry
108+
`ScrollArea`, which neither eligible set admits. The parent-edge rule needs its own decision there
109+
before it is reused, not a carry-over from the iOS claim.
110+
- **Ejection inventory precedes any API change.** 23 suppression sites across ten rules, and two of them
111+
(`scroll`, `transitions`) already rewrite rects on other nodes in the same pass that ejects — the
112+
combination the proposed API split claimed to make impossible.
113+
- **The 74 → 67 node delta on the reporting screen is unexplained.** No captured artifact exists, so
114+
neither number is an acceptance baseline.

‎docs/adr/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
| [0023 End-State Entry-to-Platform Hop Trace](0023-end-state-hop-trace.md) | the hop counting definition, the ordered `press`/Android and dual-arm `snapshot`/iOS chains, per-hop roles, and the deletion test behind the numbers ADR 0019 and ADR 0022 quote |
2828
| [0024 Screen Recording — One Coordinator, Two Facts (Proposed)](0024-screen-recording-provable-signal.md) | `record start`/`stop` lifecycle, the export-exists / recorder-stopped split, backend stop-and-collect contract, recovery through the coordinator, the ADR 0019 §5 amendment and the failed-finish cleanup fix |
2929
| [0025 Foldable Apple Panels — Capture the Lit Panel](0025-foldable-apple-panels.md) | iPhone Duo outer/inner panels, CoreDevice display info as the only screen authority, why `simctl`'s implicit display default captures a black panel, derived (never asserted) pose, and why no pose-control command exists |
30+
| [0026 Scroll Clip Authority — Ownership Is the Parent Edge (Proposed)](0026-scroll-clip-authority.md) | iOS scroll indicators and the visible band, why ownership is read from the parent edge instead of an ancestor walk, what may remove a node from an interactive snapshot, and ejection dispositions |
3031

3132
ADRs record *why*; the registries and gates they describe are the living source of truth — when
3233
prose and a registry disagree, the registry wins and the ADR needs a follow-up.

0 commit comments

Comments
 (0)