Commit d3af0e7d51

d3af0e7d51c2bac5de801bfbbed96a105c442e64

parent: dd41ed2c51

Unsigned

cmc <hello@cleberg.net> · 2026-07-22 20:02 UTC

fix(a11y): stop section-header trailing controls letter-wrapping

Reported on device: the Domain section header's Note button rendered
vertically — "N o t e", one character per line in a screen-tall capsule —
at a larger (not even accessibility-tier) text size. Same pathology the
row badges had: text inside a squeezed HStack compresses to a
one-character column instead of the layout adapting.

Three-part fix, mirroring the proven row treatment:

- CollapsibleSectionView's header is now a ViewThatFits: title, trailing
  controls, and chevron on one line while they genuinely fit; otherwise
  the trailing controls drop below the title row. Applies to every
  section header, not just Domain.
- The Note and Track bordered buttons get .fixedSize() so their text can
  never letter-wrap — their natural width is what pushes the header onto
  its stacked layout.
- The "Tracked" pill becomes an icon-only indicator (eye in a tinted
  circle) — with Pin and Note beside it the full pill was the first thing
  to compress, and the word survives for VoiceOver via its label.

Enforced audit suite stays green (7 tests, 0 failures, floor runtime).
The post-lookup header state itself is not reachable by the harness — it
requires a live lookup — so on-device confirmation closes this out.

Layout: unified · split

DomainDig/ContentView.swift +13 −1
@@ -1352,7 +1352,16 @@ struct DomainSectionView: View {
1352 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) { 1352 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
1353 if let trackedDomain { 1353 if let trackedDomain {
1354 HStack(spacing: 8) { 1354 HStack(spacing: 8) {
1355 AppStatusBadgeView(model: .init(title: "Tracked", systemImage: "eye.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))) 1355 // Icon-only: the header also carries Pin and Note, and the
1356 // full "Tracked" pill compresses at larger text sizes.
1357 // VoiceOver still hears the word via the label.
1358 Image(systemName: "eye.fill")
1359 .font(appDensity.font(.caption))
1360 .foregroundStyle(Color(.statusPositive))
1361 .padding(6)
1362 .background(Color(.statusPositiveSurface), in: Circle())
1363 .fixedSize()
1364 .accessibilityLabel("Tracked")
1356 Button { 1365 Button {
1357 onTogglePinned() 1366 onTogglePinned()
1358 } label: { 1367 } label: {
@@ -1369,6 +1378,8 @@ struct DomainSectionView: View {
1369 } 1378 }
1370 .buttonStyle(.bordered) 1379 .buttonStyle(.bordered)
1371 .font(appDensity.font(.caption)) 1380 .font(appDensity.font(.caption))
1381 // Never compress into a vertical letter column.
1382 .fixedSize()
1372 } 1383 }
1373 } 1384 }
1374 } else { 1385 } else {
@@ -1378,6 +1389,7 @@ struct DomainSectionView: View {
1378 } 1389 }
1379 .buttonStyle(.bordered) 1390 .buttonStyle(.bordered)
1380 .font(appDensity.font(.caption)) 1391 .font(appDensity.font(.caption))
1392 .fixedSize()
1381 } 1393 }
1382 } content: { 1394 } content: {
1383 CardView(allowsHorizontalScroll: false) { 1395 CardView(allowsHorizontalScroll: false) {
DomainDig/DomainDigUI.swift +41 −15
@@ -446,23 +446,26 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
446 isCollapsed.toggle() 446 isCollapsed.toggle()
447 } 447 }
448 } label: { 448 } label: {
449 HStack(alignment: .center, spacing: 10) { 449 // One line while the title, trailing controls, and chevron
450 VStack(alignment: .leading, spacing: 3) { 450 // genuinely fit; otherwise the trailing controls drop below the
451 Text(title) 451 // title. Without this, a squeezed trailing button letter-wraps
452 .font(appDensity.font(.headline, weight: .semibold)) 452 // vertically ("N o t e" as a screen-tall capsule) at larger
453 .foregroundStyle(.primary) 453 // Dynamic Type sizes — same pathology as the row badges.
454 if let subtitle { 454 ViewThatFits(in: .horizontal) {
455 Text(subtitle) 455 HStack(alignment: .center, spacing: 10) {
456 .font(appDensity.font(.caption)) 456 titleBlock
457 .foregroundStyle(Color(.appTextSecondary)) 457 Spacer(minLength: 8)
458 trailing()
459 chevron
460 }
461 VStack(alignment: .leading, spacing: 8) {
462 HStack(alignment: .center, spacing: 10) {
463 titleBlock
464 Spacer(minLength: 8)
465 chevron
458 } 466 }
467 trailing()
459 } 468 }
460 Spacer(minLength: 8)
461 trailing()
462 Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
463 .font(.caption.weight(.semibold))
464 .foregroundStyle(Color(.appTextSecondary))
465 .accessibilityHidden(true)
466 } 469 }
467 .contentShape(Rectangle()) 470 .contentShape(Rectangle())
468 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center) 471 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
@@ -482,6 +485,29 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
482 } 485 }
483 } 486 }
484 } 487 }
488
489 private var titleBlock: some View {
490 VStack(alignment: .leading, spacing: 3) {
491 Text(title)
492 .font(appDensity.font(.headline, weight: .semibold))
493 .foregroundStyle(.primary)
494 .fixedSize(horizontal: false, vertical: true)
495 .multilineTextAlignment(.leading)
496 if let subtitle {
497 Text(subtitle)
498 .font(appDensity.font(.caption))
499 .foregroundStyle(Color(.appTextSecondary))
500 .fixedSize(horizontal: false, vertical: true)
501 }
502 }
503 }
504
505 private var chevron: some View {
506 Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
507 .font(.caption.weight(.semibold))
508 .foregroundStyle(Color(.appTextSecondary))
509 .accessibilityHidden(true)
510 }
485} 511}
486 512
487/// A horizontally scrolling row of read-only tag chips, e.g. for a tracked 513/// A horizontally scrolling row of read-only tag chips, e.g. for a tracked