Skip to content

Extract ContainerContext and add AccessibilityDescribable protocol - #309

Draft
RoyalPineapple wants to merge 3 commits into
a11y-hierarchy-parsingfrom
container-context-refactor
Draft

Extract ContainerContext and add AccessibilityDescribable protocol#309
RoyalPineapple wants to merge 3 commits into
a11y-hierarchy-parsingfrom
container-context-refactor

Conversation

@RoyalPineapple

@RoyalPineapple RoyalPineapple commented Feb 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR makes accessibility descriptions regenerable from stored data by eliminating NSObject references from the container context.

Motivation

Previously, AccessibilityHierarchyParser.Context held references to live UIKit objects (e.g., UITabBarItem). This meant:

  • Context couldn't be serialized (not Codable)
  • Descriptions could only be generated at parse time
  • No way to regenerate descriptions with different verbosity settings

Solution

1. Extract reference-free ContainerContext

The new AccessibilityElement.ContainerContext stores only primitive data:

  • Series position: (index: Int, count: Int) instead of object references
  • Tab bar items: Just index/count, no UITabBarItem reference
  • Data table cells: Pre-formatted header strings instead of [NSObject]

2. Add AccessibilityDescribable protocol

Unified description generation that works for both:

  • NSObject (live UIKit elements at parse time)
  • AccessibilityElement (stored elements with containerContext)

3. Enable dynamic description regeneration

AccessibilityElement.voiceOverDescription can now recompute the description from stored properties, enabling future verbosity controls.

Also included

  • High-importance AXCustomContent now appears in descriptions (per WWDC21)
  • File rename: UIAccessibility+SnapshotAdditions.swiftUIAccessibility+RotorAdditions.swift

Test plan

  • Unit tests pass
  • ContainerContext serializes to JSON
  • voiceOverDescription regenerates identical descriptions from stored data
  • High-importance custom content appears in descriptions

🤖 Generated with Claude Code

@RoyalPineapple
RoyalPineapple force-pushed the container-context-refactor branch from 0526035 to ffc8f11 Compare February 4, 2026 16:04
@RoyalPineapple RoyalPineapple changed the title Extract ContainerContext as public Codable type Add AccessibilityDescribable protocol and hierarchical parsing API Feb 4, 2026
@RoyalPineapple
RoyalPineapple force-pushed the container-context-refactor branch from ffc8f11 to 09f2ec8 Compare February 4, 2026 17:16
@RoyalPineapple RoyalPineapple changed the title Add AccessibilityDescribable protocol and hierarchical parsing API Extract ContainerContext and add AccessibilityDescribable protocol Feb 4, 2026
@RoyalPineapple
RoyalPineapple force-pushed the a11y-hierarchy-parsing branch 2 times, most recently from f5e9f91 to 99cb260 Compare February 5, 2026 10:48
@RoyalPineapple
RoyalPineapple force-pushed the container-context-refactor branch 3 times, most recently from d56de3a to 35c20f3 Compare February 5, 2026 11:35
Introduces `parseAccessibilityHierarchy()` which returns accessibility
data as a tree structure preserving container relationships, compared
to the existing `parseAccessibilityElements()` which returns a flat list.

New types:
- `AccessibilityHierarchy`: Recursive enum with `.element` and `.container` cases
- `AccessibilityContainer`: Container metadata (type, frame, traits)
- `ContainerType`: Enum for semantic groups, lists, landmarks, data tables, tab bars

All types conform to Codable for serialization. The hierarchy can be
flattened back to elements via `flattenToElements()` for compatibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
RoyalPineapple and others added 2 commits February 10, 2026 19:53
- Add AccessibilityDescribable protocol for unified VoiceOver description generation
- Extract ContainerContext from internal parser type to public AccessibilityElement.ContainerContext
- Add containerContext property to AccessibilityElement for storing parse-time context
- Add voiceOverDescription computed property to regenerate descriptions from stored properties
- Include high-importance custom content in accessibility descriptions (per WWDC21)
- Rename UIAccessibility+SnapshotAdditions.swift to UIAccessibility+RotorAdditions.swift

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@RoyalPineapple
RoyalPineapple force-pushed the container-context-refactor branch from 35c20f3 to 7e7f707 Compare February 10, 2026 18:53
RoyalPineapple added a commit to RoyalPineapple/AccessibilitySnapshotBH that referenced this pull request Jul 12, 2026
…tion materialization (#9)

* Add VerbosityConfiguration to model (from cashapp cashapp#309)

Ports cashapp#309's VerbosityConfiguration verbatim into the agnostic model as
the config vocabulary for late description assembly. Pure Foundation,
builds on the model target. Nothing consumes it yet.

* Move localization into the agnostic model

Relocates the localized Strings struct, String+Localization, and the
{en,de,ru}.lproj Assets from the Parser target into AccessibilitySnapshotModel
so late (verbosity-driven) description assembly can run on any platform.

- Strings + localized(...) + StringLocalization made public for the Parser.
- Model manifests gain defaultLocalization: en + resources: [.process(Assets)];
  Parser target drops the Assets resource; Tuist mirrors the move.
- Fixes a latent bug: .lproj lookup used subdirectory: Assets, but
  .process(Assets) flattens .lproj to the bundle root, so de/ru silently fell
  back to English. Now looks at the bundle root. New StringsLocalizationTests
  proves de resolves (Taste., not Button.). 31 model tests green; iOS app builds.

* Relocate description assembly into the model as a pure verbosity fold

Ports the parse-time NSObject.accessibilityDescription(context:) into the model
as AccessibilityElement.description(context:verbosity:), reading only stored
model data + a graph-derived DerivedContext. This un-bakes the description: it
is now a pure, platform-independent function of (element, context, verbosity)
that reproduces the historical string at .verbose.

- DerivedContext: ref-free mirror of the parser Context; dataTable headers are
  resolved HeaderText(label,value) rather than live NSObjects, so they stay
  re-gatable under includesTableContext.
- traitPosition (.before/.after/.none) mirrors iOS 18.4 Verbosity > Controls
  (Speak Before / Speak After / Don't Speak); .after is the historical default.
- 11 assembly tests pin the three trait positions, verbosity gating, container
  context, and hints. 41 model tests green.

Parser still uses its own parse-time path; wiring the parser to call this and
consuming dataTable cells is the next step.

* Emit .series container node for segmented controls (class-free)

Step 4a (part 1): make the graph self-describing for series.

- Add ContainerType.series to the model (enum + hand-rolled Codable),
  distinct from .tabBar: series members keep their Button trait and
  append "N of M", tabs replace Button with "Tab.".
- Detect segmented controls class-free via accessibilityContainerType
  == 11 (private value, read through the public property; same idiom as
  private trait bits). Confirmed live + specific by accessibility
  research: steppers/sliders/date-pickers return 0.
- The new node is transparent to rendering (flattenToElements drops
  containers); segment descriptions still come from the parse-time path
  until the 4c cutover.

Byte-identity: testSegmentedControl + testTabBars + full DefaultControls
and AccessibilitySnapshotTests suites unchanged (23/23); 41 model tests
green.

* Classify tab bars class-free via .tabBarItem children

Step 4a (part 2): make the graph self-describing for tab bars.

A container whose children carry the private .tabBarItem trait (bit 28)
is a tab bar. This recognizes a real UITabBar without an `is UITabBar`
check: UITabBar reports accessibilityContainerType == .semanticGroup and
carries no .tabBar trait, so today it emits as a semanticGroup node; the
children-trait rule reclassifies it to .tabBar. Custom .tabBar-trait
views remain matched by the trait.

The node-type change is transparent to rendering (flattenToElements
drops containers) and descriptions still come from the parse-time path,
so this is byte-identity-safe until the 4c cutover. Confirmed live
byte-identical (bit 28) on iOS 18.5 + 26.3 by accessibility research;
UIStepper/UISlider/UIPageControl carry no such children and are not
misclassified.

Byte-identity: testTabBars + full DefaultControls (incl. page control,
stepper, slider) unchanged. SwiftUIListSectionTests failures are
pre-existing (fail on baseline without this change).

* Derive container context from graph position (model, 4b)

Step 4b: pure model-side derivation mirroring the parser's live
derivedContext, UIKit-free.

AccessibilityContainer.derivedContext(forChildAt:in:) reads a child's
context from graph position alone:
- .series  -> .series(index: ordinal+1, count: siblings)
- .tabBar  -> .tab(index: ordinal+1, count: siblings)  (.tab ==.tabBarItem)
- .list    -> .listStart / .listEnd (sole child: start only)
- .landmark-> .landmarkStart / .landmarkEnd
- .dataTable-> resolve stored cells[i] header child-indices into sibling
  label/value as HeaderText (over the full child set, before pruning)
- semanticGroup / scrollable / .none -> nil

The {index, count} this computes is the same value VoiceOver derives from
_accessibilityRowRange (which for tab bars UIKit itself computes from
sibling position) -- so we reproduce it from the tree with no SPI call.

Not yet wired into delivery (that is 4c); the parser's parse-time path
still produces descriptions. 9 derivation tests; 50 model tests green.

* Materialize descriptions at delivery from graph context (4c)

Cut the spoken description/hint over from parse-time baking to a delivery-time
fold: parse stamps raw facts + container structure, and
`materializingDescriptions(verbosity:)` composes the final string from
graph-derived context just before on-screen trimming. Wired into both the UIKit
and SwiftUI delivery sites; `.verbose` (the default) reproduces historical output.

Three latent byte-identity bugs the live cutover surfaced (all dormant while
descriptions were baked):

- Subview-based list/landmark got context the old parser withheld: emit
  .list/.landmark only on the container-API path (explicitlyOrdered); subview-
  vended containers fall back to .semanticGroup, matching superviewContextParent's
  historical nil. Faithful subview boundaries stay deferred (plan P5).
- Doubled hint: the parser baked the composed hint (raw + trait suffix) into
  element.hint, so re-composition at delivery doubled switch/adjustable hints.
  buildElement now stores the raw author hint; the suffix is composed once, late.
- Important custom content was folded into the description string; the historical
  parser never did this (the legend renders it separately). Removed.

Full snapshot suite: only the pre-existing SwiftUIListSectionTests failures remain
(confirmed identical on a clean baseline). 50 model tests + new parity localizer
green.

* Preserve .list/.landmark node type on the subview path (4c fixup)

The 4c cutover briefly remapped subview-vended .list/.landmark containers to
.semanticGroup to force description byte-identity. That broke the container
structural contract (testListContainerIsAlwaysPreserved / ...Landmark...): a
.list/.landmark view must emit its node type regardless of how it vends its
children, so query consumers can find it by type. CI Tuist UnitTests caught it.

Revert to always emitting .list/.landmark. Subview-based lists now correctly gain
their real 'List Start'/'List End' boundaries via graph derivation -- exposing
that accessibility information is the point of this refactor, not a divergence to
suppress. Both current snapshot fixtures use the container-API path, so reference
PNGs are unchanged; 91 UnitTests + full snapshot suite green (only pre-existing
TextField/TextView + SwiftUIListSectionTests failures remain).

* Contextualize at the render boundary: materializing flatten + container-aware SwiftUI legend (4d)

Flattening is the moment the container structure is dropped, so it is now
also the moment each element's graph-derived context is folded into its
final rendered string. `flattenToElements(verbosity: = .verbose)` derives
context per child as it walks (same composition the deleted
`materializingDescriptions` pass performed) and returns terminal,
render-ready elements. Existing call sites compile unchanged and get
materialized descriptions for free; delivery sites flatten the FULL tree
(so "X of N" counts and data-table headers derive from complete child
sets) and prune the flat array by visibility afterwards.

The UIKit render path is unchanged: same flat markers, same per-marker
legend, same stored-description reads, byte-identical snapshots.

The SwiftUI legend gains an opt-in container-aware mode ported from
cashapp#329 (closed): `showContainers` renders the legend hierarchically
with dashed container borders and badges. Unlike cashapp#329, the `.element`
case composes `description(context:verbosity:)` live from the element's
graph position instead of reading the stored string — the graph walk IS
the contextualizer on this path, so context is never stored on the
element and re-contextualization cannot double (the composer reads only
raw facts).

- HierarchyColorAssignment: threads DerivedContext down the assign walk,
  composes at each element, filters by visibility against the full child
  set; elements keep their flat traversal indices (overlays identical),
  containers numbered pre-order after all elements
- HierarchyLegendView / ContainerLegendEntryView: ported from cashapp#329
- ParsedAccessibilityData.hierarchy: data plumbing so the SwiftUI
  renderer can reach the graph; the UIKit renderer ignores it
- withDescription made public, documented as a terminal projection
- ContainerDemo fixture + snapshot tests (with/without containers)

Suites: 50 model tests green; UnitTests green; SnapshotTests only
pre-existing failures (TextField/TextView, SwiftUIListSection x2);
PreviewsTests green except pre-existing locale-dependent
testCustomContentDemo (demo formats 2847 via device locale; scheme does
not pin a language).

* TEMP: record container demo references on the 26.2 CI runner

The iOS 26.2 simulator runtime is no longer downloadable from Apple, so
the two new container-demo references for the iOS_26 CI matrix entry
can't be recorded locally. Gate record mode to 26.2 so the runner
records them, and extend the failure-artifact upload to include the
Previews reference images (durable improvement) so the recorded PNGs
can be harvested.

The record-mode flip will be reverted once the images are committed.

* Add recorded 26.2 container references; rename HierarchyColorAssignment to ContextualizedHierarchy

- Harvest the two ContainerDemo 26.2 reference images recorded on CI
  (the 26.2 simulator runtime is no longer downloadable locally) and
  revert the temporary OS-gated recordMode lines.
- Rename HierarchyColorAssignment -> ContextualizedHierarchy: the type
  applies graph-derived context to each element (composed description,
  hint, and overlay color index), not just color assignment. AssignedNode
  becomes Node. No behavior change.

* Separate marker numbering from ContextualizedHierarchy

Numbering (and the color each number selects) is a property of the
snapshot rendering, not the hierarchy. ContextualizedHierarchy now
carries only context application — description and hint composed from
graph position, container structure preserved — and HierarchyLegendView
assigns indices as it renders: elements in flat traversal order
(matching the markers array), containers pre-order after all elements.
No pixel change.

* Document ContainerType.tabBar's two capture channels

* Model hint decomposition: user fact, computed state utterances, computed spoken merge

The user-set hint stays the stored fact (element.hint, raw since the
un-bake); the VoiceOver state utterances become a computed accessor
(stateHint(verbosity:)); the spoken merge stays the hint half of
description(context:verbosity:). All three run the same pipeline —
the historical merge rules (switch wraps, text entry replaces,
adjustable chains) are factored into one private function that the
spoken path feeds the real hint and the state path feeds nil, so
spoken == merge(user, state) by construction and nothing new is
stored anywhere. Spoken output is byte-identical (verbatim factoring).

* One contextualize walk: model owns the canonical→marker projection

contextualized(verbosity:) on [AccessibilityHierarchy] is now THE
contextualize step — the single walk that threads DerivedContext down
the tree and composes each element's spoken strings. flattenToElements
becomes contextualize-then-plain-flatten, and the Previews
ContextualizedHierarchy.build becomes pure structure (visibility filter
+ empty-container drop) over the same walk, so both legend modes speak
identically by construction and the duplicated context walk is gone.

Also documents (finding 149, iOS 26.3): real VoiceOver speaks N-of-M /
table position trailing like us, but weaves list/landmark boundary
phrases into the trait-specifier position — we keep the historical
trailing placement for byte-identity.

Gates: 54 model tests; Previews snapshot suite incl. both ContainerDemo
refs (sole failure = pre-existing locale-environmental
testCustomContentDemo); parity + parser + index-API unit tests green.
RoyalPineapple added a commit to RoyalPineapple/AccessibilitySnapshotBH that referenced this pull request Aug 13, 2026
…tion materialization (#9)

* Add VerbosityConfiguration to model (from cashapp cashapp#309)

Ports cashapp#309's VerbosityConfiguration verbatim into the agnostic model as
the config vocabulary for late description assembly. Pure Foundation,
builds on the model target. Nothing consumes it yet.

* Move localization into the agnostic model

Relocates the localized Strings struct, String+Localization, and the
{en,de,ru}.lproj Assets from the Parser target into AccessibilitySnapshotModel
so late (verbosity-driven) description assembly can run on any platform.

- Strings + localized(...) + StringLocalization made public for the Parser.
- Model manifests gain defaultLocalization: en + resources: [.process(Assets)];
  Parser target drops the Assets resource; Tuist mirrors the move.
- Fixes a latent bug: .lproj lookup used subdirectory: Assets, but
  .process(Assets) flattens .lproj to the bundle root, so de/ru silently fell
  back to English. Now looks at the bundle root. New StringsLocalizationTests
  proves de resolves (Taste., not Button.). 31 model tests green; iOS app builds.

* Relocate description assembly into the model as a pure verbosity fold

Ports the parse-time NSObject.accessibilityDescription(context:) into the model
as AccessibilityElement.description(context:verbosity:), reading only stored
model data + a graph-derived DerivedContext. This un-bakes the description: it
is now a pure, platform-independent function of (element, context, verbosity)
that reproduces the historical string at .verbose.

- DerivedContext: ref-free mirror of the parser Context; dataTable headers are
  resolved HeaderText(label,value) rather than live NSObjects, so they stay
  re-gatable under includesTableContext.
- traitPosition (.before/.after/.none) mirrors iOS 18.4 Verbosity > Controls
  (Speak Before / Speak After / Don't Speak); .after is the historical default.
- 11 assembly tests pin the three trait positions, verbosity gating, container
  context, and hints. 41 model tests green.

Parser still uses its own parse-time path; wiring the parser to call this and
consuming dataTable cells is the next step.

* Emit .series container node for segmented controls (class-free)

Step 4a (part 1): make the graph self-describing for series.

- Add ContainerType.series to the model (enum + hand-rolled Codable),
  distinct from .tabBar: series members keep their Button trait and
  append "N of M", tabs replace Button with "Tab.".
- Detect segmented controls class-free via accessibilityContainerType
  == 11 (private value, read through the public property; same idiom as
  private trait bits). Confirmed live + specific by accessibility
  research: steppers/sliders/date-pickers return 0.
- The new node is transparent to rendering (flattenToElements drops
  containers); segment descriptions still come from the parse-time path
  until the 4c cutover.

Byte-identity: testSegmentedControl + testTabBars + full DefaultControls
and AccessibilitySnapshotTests suites unchanged (23/23); 41 model tests
green.

* Classify tab bars class-free via .tabBarItem children

Step 4a (part 2): make the graph self-describing for tab bars.

A container whose children carry the private .tabBarItem trait (bit 28)
is a tab bar. This recognizes a real UITabBar without an `is UITabBar`
check: UITabBar reports accessibilityContainerType == .semanticGroup and
carries no .tabBar trait, so today it emits as a semanticGroup node; the
children-trait rule reclassifies it to .tabBar. Custom .tabBar-trait
views remain matched by the trait.

The node-type change is transparent to rendering (flattenToElements
drops containers) and descriptions still come from the parse-time path,
so this is byte-identity-safe until the 4c cutover. Confirmed live
byte-identical (bit 28) on iOS 18.5 + 26.3 by accessibility research;
UIStepper/UISlider/UIPageControl carry no such children and are not
misclassified.

Byte-identity: testTabBars + full DefaultControls (incl. page control,
stepper, slider) unchanged. SwiftUIListSectionTests failures are
pre-existing (fail on baseline without this change).

* Derive container context from graph position (model, 4b)

Step 4b: pure model-side derivation mirroring the parser's live
derivedContext, UIKit-free.

AccessibilityContainer.derivedContext(forChildAt:in:) reads a child's
context from graph position alone:
- .series  -> .series(index: ordinal+1, count: siblings)
- .tabBar  -> .tab(index: ordinal+1, count: siblings)  (.tab ==.tabBarItem)
- .list    -> .listStart / .listEnd (sole child: start only)
- .landmark-> .landmarkStart / .landmarkEnd
- .dataTable-> resolve stored cells[i] header child-indices into sibling
  label/value as HeaderText (over the full child set, before pruning)
- semanticGroup / scrollable / .none -> nil

The {index, count} this computes is the same value VoiceOver derives from
_accessibilityRowRange (which for tab bars UIKit itself computes from
sibling position) -- so we reproduce it from the tree with no SPI call.

Not yet wired into delivery (that is 4c); the parser's parse-time path
still produces descriptions. 9 derivation tests; 50 model tests green.

* Materialize descriptions at delivery from graph context (4c)

Cut the spoken description/hint over from parse-time baking to a delivery-time
fold: parse stamps raw facts + container structure, and
`materializingDescriptions(verbosity:)` composes the final string from
graph-derived context just before on-screen trimming. Wired into both the UIKit
and SwiftUI delivery sites; `.verbose` (the default) reproduces historical output.

Three latent byte-identity bugs the live cutover surfaced (all dormant while
descriptions were baked):

- Subview-based list/landmark got context the old parser withheld: emit
  .list/.landmark only on the container-API path (explicitlyOrdered); subview-
  vended containers fall back to .semanticGroup, matching superviewContextParent's
  historical nil. Faithful subview boundaries stay deferred (plan P5).
- Doubled hint: the parser baked the composed hint (raw + trait suffix) into
  element.hint, so re-composition at delivery doubled switch/adjustable hints.
  buildElement now stores the raw author hint; the suffix is composed once, late.
- Important custom content was folded into the description string; the historical
  parser never did this (the legend renders it separately). Removed.

Full snapshot suite: only the pre-existing SwiftUIListSectionTests failures remain
(confirmed identical on a clean baseline). 50 model tests + new parity localizer
green.

* Preserve .list/.landmark node type on the subview path (4c fixup)

The 4c cutover briefly remapped subview-vended .list/.landmark containers to
.semanticGroup to force description byte-identity. That broke the container
structural contract (testListContainerIsAlwaysPreserved / ...Landmark...): a
.list/.landmark view must emit its node type regardless of how it vends its
children, so query consumers can find it by type. CI Tuist UnitTests caught it.

Revert to always emitting .list/.landmark. Subview-based lists now correctly gain
their real 'List Start'/'List End' boundaries via graph derivation -- exposing
that accessibility information is the point of this refactor, not a divergence to
suppress. Both current snapshot fixtures use the container-API path, so reference
PNGs are unchanged; 91 UnitTests + full snapshot suite green (only pre-existing
TextField/TextView + SwiftUIListSectionTests failures remain).

* Contextualize at the render boundary: materializing flatten + container-aware SwiftUI legend (4d)

Flattening is the moment the container structure is dropped, so it is now
also the moment each element's graph-derived context is folded into its
final rendered string. `flattenToElements(verbosity: = .verbose)` derives
context per child as it walks (same composition the deleted
`materializingDescriptions` pass performed) and returns terminal,
render-ready elements. Existing call sites compile unchanged and get
materialized descriptions for free; delivery sites flatten the FULL tree
(so "X of N" counts and data-table headers derive from complete child
sets) and prune the flat array by visibility afterwards.

The UIKit render path is unchanged: same flat markers, same per-marker
legend, same stored-description reads, byte-identical snapshots.

The SwiftUI legend gains an opt-in container-aware mode ported from
cashapp#329 (closed): `showContainers` renders the legend hierarchically
with dashed container borders and badges. Unlike cashapp#329, the `.element`
case composes `description(context:verbosity:)` live from the element's
graph position instead of reading the stored string — the graph walk IS
the contextualizer on this path, so context is never stored on the
element and re-contextualization cannot double (the composer reads only
raw facts).

- HierarchyColorAssignment: threads DerivedContext down the assign walk,
  composes at each element, filters by visibility against the full child
  set; elements keep their flat traversal indices (overlays identical),
  containers numbered pre-order after all elements
- HierarchyLegendView / ContainerLegendEntryView: ported from cashapp#329
- ParsedAccessibilityData.hierarchy: data plumbing so the SwiftUI
  renderer can reach the graph; the UIKit renderer ignores it
- withDescription made public, documented as a terminal projection
- ContainerDemo fixture + snapshot tests (with/without containers)

Suites: 50 model tests green; UnitTests green; SnapshotTests only
pre-existing failures (TextField/TextView, SwiftUIListSection x2);
PreviewsTests green except pre-existing locale-dependent
testCustomContentDemo (demo formats 2847 via device locale; scheme does
not pin a language).

* TEMP: record container demo references on the 26.2 CI runner

The iOS 26.2 simulator runtime is no longer downloadable from Apple, so
the two new container-demo references for the iOS_26 CI matrix entry
can't be recorded locally. Gate record mode to 26.2 so the runner
records them, and extend the failure-artifact upload to include the
Previews reference images (durable improvement) so the recorded PNGs
can be harvested.

The record-mode flip will be reverted once the images are committed.

* Add recorded 26.2 container references; rename HierarchyColorAssignment to ContextualizedHierarchy

- Harvest the two ContainerDemo 26.2 reference images recorded on CI
  (the 26.2 simulator runtime is no longer downloadable locally) and
  revert the temporary OS-gated recordMode lines.
- Rename HierarchyColorAssignment -> ContextualizedHierarchy: the type
  applies graph-derived context to each element (composed description,
  hint, and overlay color index), not just color assignment. AssignedNode
  becomes Node. No behavior change.

* Separate marker numbering from ContextualizedHierarchy

Numbering (and the color each number selects) is a property of the
snapshot rendering, not the hierarchy. ContextualizedHierarchy now
carries only context application — description and hint composed from
graph position, container structure preserved — and HierarchyLegendView
assigns indices as it renders: elements in flat traversal order
(matching the markers array), containers pre-order after all elements.
No pixel change.

* Document ContainerType.tabBar's two capture channels

* Model hint decomposition: user fact, computed state utterances, computed spoken merge

The user-set hint stays the stored fact (element.hint, raw since the
un-bake); the VoiceOver state utterances become a computed accessor
(stateHint(verbosity:)); the spoken merge stays the hint half of
description(context:verbosity:). All three run the same pipeline —
the historical merge rules (switch wraps, text entry replaces,
adjustable chains) are factored into one private function that the
spoken path feeds the real hint and the state path feeds nil, so
spoken == merge(user, state) by construction and nothing new is
stored anywhere. Spoken output is byte-identical (verbatim factoring).

* One contextualize walk: model owns the canonical→marker projection

contextualized(verbosity:) on [AccessibilityHierarchy] is now THE
contextualize step — the single walk that threads DerivedContext down
the tree and composes each element's spoken strings. flattenToElements
becomes contextualize-then-plain-flatten, and the Previews
ContextualizedHierarchy.build becomes pure structure (visibility filter
+ empty-container drop) over the same walk, so both legend modes speak
identically by construction and the duplicated context walk is gone.

Also documents (finding 149, iOS 26.3): real VoiceOver speaks N-of-M /
table position trailing like us, but weaves list/landmark boundary
phrases into the trait-specifier position — we keep the historical
trailing placement for byte-identity.

Gates: 54 model tests; Previews snapshot suite incl. both ContainerDemo
refs (sole failure = pre-existing locale-environmental
testCustomContentDemo); parity + parser + index-API unit tests green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant