Purpose
snapshot -i lost every list row after a row holding selectable text (#2214 in 0.21.0, patched for that
one type by #2740). The type-specific fix closed one instance. The class stays open: presentation still
finds a scroll indicator's owner by walking up past its parent, so any host that scrolls but publishes
as a non-scroll type (WebView, a map view, a paged cell) misattributes its indicator to the enclosing
list and clips that list to the host's band. Measured: a synthetic Element(58) row with its own indicator
drops Reply on origin/main.
Design record: ADR 0026 — Scroll Clip Authority: Ownership Is the Parent Edge (proposed, #2755).
Required behavior
Step 1 — ownership is read from the parent edge. The owner of an indicator is its parent, and a band
derives only when that parent is a scroll type. Delete the ancestor walk and isTextViewType from
packages/capture-kit/src/ios-snapshot-engine/scroll.ts. Evidence already collected on a scratch
branch: pnpm test:unit → 10,959 passed / 1 skipped with this substitution, and the Element(58) shape
above stops dropping rows. Land it with the red→green cases from step 2.
Step 2 — extend the differential to the runner stage. assertProjectionSubsets
(packages/capture-kit/src/ios-snapshot-engine/properties.test.ts) asserts interactive ⊆ regular ⊆ raw
but builds acquired inputs only; the Swift differential drops interactive cases. Add a stage: 'presented'
arm plus authored cases for a TextView row (#2740's shape) and a WebView row. Each must fail on
origin/main first, per docs/agents/testing.md. Assert source-level membership, not literally
interactive ⊆ full: the full projection skips semantic compaction.
Step 3 — survey real captured trees for under-clipping. Removing the walk can lose a band when a
real tree places an indicator under a labelled wrapper instead of directly under its scroll view, letting
content scrolled under the chrome survive — the leak class #1784/#1797 removed. Survey captured runner
trees before landing step 1, and record the count of indicators whose parent is not a scroll type. If the
shape is real, decide on evidence whether to skip unlabeled Other wrappers — do not add the tolerance
speculatively.
Step 4 — pair the two eligibility lists, and settle ScrollArea for macOS.
REGULAR_ELIGIBLE_TYPES (ios-snapshot-engine/projection.ts) and eligibleInteractiveTypes
(apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationProjection.swift)
are one fact in two languages and no test currently references either. They are what makes the parent edge
survive projection: an ineligible wrapper is re-parented to the nearest presented ancestor, which is the
scroll host. Add a shared-table or dual-assertion guard. Note the pair excludes scrollarea while
isScrollableSnapshotType includes it: the iOS runner never emits that type, but macOS desktop capture
reaches these rules (snapshot-desktop-surface.ts → ios-snapshot-runtime.ts → publishIosSnapshot) and
its helper emits ScrollArea. Decide whether macOS needs it eligible before the rule is reused there —
do not resolve it by editing a set for iOS's sake.
Step 5 — inventory ejections, then make them typed. ~23 suppressNode call sites across ten rules
(rows, web, transitions, noise-search, noise-redundancy, noise-structural, noise-overlay,
noise-viewport, action-shelf, scroll); at least two also rewrite rects. Extend the existing
presentedIndexesBySourceIndex (ios-snapshot-engine/semantic-index.ts) into a typed disposition — every
source ends presented with representatives, or removed with at least one reason. If eject requires a
reason the ledger is complete by construction, so no separate gate is needed. Internal evidence only: no
suppression vocabulary on the snapshot wire.
Observable completion conditions
- A
stage: 'presented' case for both the TextView and WebView row shapes fails on origin/main and
passes with the change.
- No band can derive from anything other than a parent that is a scroll type; grep proves no ancestor walk
remains in scroll.ts.
- The wrapper survey result is recorded on this issue with tree counts, and any tolerance it justifies
carries a captured-tree fixture.
- Eligibility parity is enforced, not asserted in prose, and the
ScrollArea question is answered for
macOS surfaces — either decided with a case, or recorded as out of scope with the reason.
pnpm check:affected --run on the exact pushed head, plus the engine conformance and differential suites.
New ownership cases join contracts/fixtures/ios-snapshot-engine-conformance.json; they join the Swift
fixture only once Swift implements the same behavior.
Non-goals
- A producer-reported ownership field or capture-local index remapping: the parent edge already carries it.
- Producer-computed visible bands.
- A scroll-capability matrix, or adding
TextView/WebView to a universal scroll-container set.
- A rule-graph/effect framework, or a two-API reshape/eject split before step 5's inventory.
- Treating the unexplained 74 → 67 node delta as an acceptance baseline without a captured artifact.
Dependencies and sequencing
Related: #2214 (introduced), #2740 (type-specific fix), ADR 0004 §host-side ownership boundary and the
#2638 regression note ("an absent fact is not a negative answer").
Purpose
snapshot -ilost every list row after a row holding selectable text (#2214 in 0.21.0, patched for thatone type by #2740). The type-specific fix closed one instance. The class stays open: presentation still
finds a scroll indicator's owner by walking up past its parent, so any host that scrolls but publishes
as a non-scroll type (
WebView, a map view, a paged cell) misattributes its indicator to the enclosinglist and clips that list to the host's band. Measured: a synthetic
Element(58)row with its own indicatordrops
Replyonorigin/main.Design record: ADR 0026 — Scroll Clip Authority: Ownership Is the Parent Edge (proposed, #2755).
Required behavior
Step 1 — ownership is read from the parent edge. The owner of an indicator is its parent, and a band
derives only when that parent is a scroll type. Delete the ancestor walk and
isTextViewTypefrompackages/capture-kit/src/ios-snapshot-engine/scroll.ts. Evidence already collected on a scratchbranch:
pnpm test:unit→ 10,959 passed / 1 skipped with this substitution, and theElement(58)shapeabove stops dropping rows. Land it with the red→green cases from step 2.
Step 2 — extend the differential to the runner stage.
assertProjectionSubsets(
packages/capture-kit/src/ios-snapshot-engine/properties.test.ts) asserts interactive ⊆ regular ⊆ rawbut builds acquired inputs only; the Swift differential drops interactive cases. Add a
stage: 'presented'arm plus authored cases for a
TextViewrow (#2740's shape) and aWebViewrow. Each must fail onorigin/mainfirst, perdocs/agents/testing.md. Assert source-level membership, not literallyinteractive ⊆ full: the full projection skips semantic compaction.Step 3 — survey real captured trees for under-clipping. Removing the walk can lose a band when a
real tree places an indicator under a labelled wrapper instead of directly under its scroll view, letting
content scrolled under the chrome survive — the leak class #1784/#1797 removed. Survey captured runner
trees before landing step 1, and record the count of indicators whose parent is not a scroll type. If the
shape is real, decide on evidence whether to skip unlabeled
Otherwrappers — do not add the tolerancespeculatively.
Step 4 — pair the two eligibility lists, and settle
ScrollAreafor macOS.REGULAR_ELIGIBLE_TYPES(ios-snapshot-engine/projection.ts) andeligibleInteractiveTypes(
apple/snapshot-presentation/Sources/AgentDeviceSnapshotPresentation/SnapshotPresentationProjection.swift)are one fact in two languages and no test currently references either. They are what makes the parent edge
survive projection: an ineligible wrapper is re-parented to the nearest presented ancestor, which is the
scroll host. Add a shared-table or dual-assertion guard. Note the pair excludes
scrollareawhileisScrollableSnapshotTypeincludes it: the iOS runner never emits that type, but macOS desktop capturereaches these rules (
snapshot-desktop-surface.ts→ios-snapshot-runtime.ts→publishIosSnapshot) andits helper emits
ScrollArea. Decide whether macOS needs it eligible before the rule is reused there —do not resolve it by editing a set for iOS's sake.
Step 5 — inventory ejections, then make them typed. ~23
suppressNodecall sites across ten rules(
rows,web,transitions,noise-search,noise-redundancy,noise-structural,noise-overlay,noise-viewport,action-shelf,scroll); at least two also rewrite rects. Extend the existingpresentedIndexesBySourceIndex(ios-snapshot-engine/semantic-index.ts) into a typed disposition — everysource ends presented with representatives, or removed with at least one reason. If
ejectrequires areason the ledger is complete by construction, so no separate gate is needed. Internal evidence only: no
suppression vocabulary on the snapshot wire.
Observable completion conditions
stage: 'presented'case for both theTextViewandWebViewrow shapes fails onorigin/mainandpasses with the change.
remains in
scroll.ts.carries a captured-tree fixture.
ScrollAreaquestion is answered formacOS surfaces — either decided with a case, or recorded as out of scope with the reason.
pnpm check:affected --runon the exact pushed head, plus the engine conformance and differential suites.New ownership cases join
contracts/fixtures/ios-snapshot-engine-conformance.json; they join the Swiftfixture only once Swift implements the same behavior.
Non-goals
TextView/WebViewto a universal scroll-container set.Dependencies and sequencing
runner-presentation.test.tspins the band contract, so the band must not be deleted to dodge theproblem.
Related: #2214 (introduced), #2740 (type-specific fix), ADR 0004 §host-side ownership boundary and the
#2638 regression note ("an absent fact is not a negative answer").