Removes the long-press “why is this here?” explainability from packing rows (packing sheet only). Task-side explainability is untouched. Rebased on top of the just-merged #11 (per-trip notes + sub-items).
WhyDisclosure panel and the “Why is this here?” VoiceOver action; TaskRow explainability is unchanged.WhyResolver.reason(…) SwiftData fetches that ran on accessibility-action setup and on every disclosure-open / attribute / rules change.WhyResolver.reason(for: TripPackingItem) and WhyDisclosure.Style.packing become test-only; intentionally retained (documented), not dead code.Ready to push
Deletion-heavy, behaviour-scoped change with no correctness issues found by any of the four review agents. The four agents (reuse / quality / efficiency / spec) confirmed: no dangling references, removals are correct, and the change is a net efficiency win (it deletes per-row SwiftData fetches). All review findings were addressed — the only substantive one was a process gap (missing decision-log entry), now recorded as phase-4 Decision 10. Build and lint pass.
5102599 Remove long-press 'why' disclosure from packing list rows 9f44bab Merge origin/main (#11 per-trip notes + sub-items) working-tree Fixes applied in this review In the packing list, each item used to respond to a long-press by showing a little “Why is this here?” panel explaining why the rules engine added it. The owner found this unused and the gesture made the rows feel fiddly, so it was removed — from the packing list only. The same feature on the task list is untouched.
Fewer gestures and less visual noise on already busy packing rows. As a bonus, the app no longer does a small database lookup for every packing row just to decide whether to offer that panel.
The change is confined to two SwiftUI views — PackingItemRow and PackingSheet. Removed: the .onLongPressGesture on the item name, the inline WhyDisclosureView, the WhyAccessibilityAction modifier + its hasWhyJustification gate, the resolvedReason state with its three .onChange recompute handlers, and the sheet-level openDisclosureItemID state / toggleDisclosure / disclosure branch of the background tap-catcher. The sheet's close button now calls onDismiss directly instead of routing through a handleClose that first closed an open disclosure.
This landed on top of #11 (notes + sub-items), which had coupled its inline editors to the disclosure: handleInlineEditorVisibility closed an open disclosure when an editor opened. With the disclosure gone, that method became a one-line pass-through and was inlined into the .onChange(of: isAnyInlineEditorOpen). The shared WhyResolver / WhyDisclosureView stay because TaskRow still uses them.
Packing loses in-app explainability (sighted + VoiceOver). The TripPackingItem resolver overload and .packing style are now test-only but kept — tearing them out would churn a shared resolver the task path depends on for no behavioural gain.
The deletion removes the only packing-side caller of WhyResolver.reason(for: TripPackingItem, …), which performs a FetchDescriptor<MasterPackingItem> fetch via fetchMaster. Previously that fetch ran (a) once per row during WhyAccessibilityAction setup (hasWhyJustification) on every row build, and (b) on each of three onChange triggers (isDisclosureOpen, trip.attributesData, currentlyMatchesRules). All gone — a measurable reduction in main-context SwiftData work on the packing sheet.
The merge with #11 required re-applying the removal over a restructured PackingItemRow (now rowContent + nameColumn + PackingSubItemsView). nameColumn shed its VStack wrapper (the disclosure was its second child) and is now a single Text; the outer VStack(spacing: 6) that hosts the sub-items sibling remains in body. The import SwiftData was dropped from PackingItemRow (no remaining ModelContext use); PackingSheet keeps it (PackingItemGroup still commits via hook.commit(modelContext)).
This supersedes accepted requirements (Phase 4 Req 6.5 / §7 / 9.2 / 9.9; Phase 6 Req 8.5 / 9.5 for PackingItemRow). Per project convention that's recorded as phase-4 Decision 10. The pre-existing PackingItemRowAccessibilityTests isolation crash (fails under narrow -only-testing, passes in the full target) is documented in packing-sheet.md so the full make test-quick remains the source of truth for that suite.
Scramble/Scramble/Components/PackingItemRow.swift
Why it matters. Removes the user-facing gesture/panel and deletes per-row SwiftData fetches (hasWhyJustification + three onChange recomputes) from the packing sheet hot path.
What to look at. PackingItemRow.swift — nameColumn, rowContent, removed onChange handlers & WhyAccessibilityAction
Scramble/Scramble/Components/PackingItemRow.swift
Why it matters. #11 had handleInlineEditorVisibility close an open disclosure when a note/sub-item editor opened. With no disclosure, that coupling is dead.
What to look at. PackingItemRow.swift — handleInlineEditorVisibility removed; logic inlined into .onChange(of: isAnyInlineEditorOpen)
Scramble/Scramble/Features/Trips/PackingSheet.swift
Why it matters. openDisclosureItemID, toggleDisclosure, and handleClose existed only for the disclosure; the background tap-catcher now handles only the inline add field.
What to look at. PackingSheet.swift — removed openDisclosureItemID/toggleDisclosure/handleClose; onClose wired to onDismiss
specs/phase-4-packing-sheet/decision_log.md
Why it matters. The removal overturns accepted Phase 4 / Phase 6 requirements; project convention requires an Enhanced Nygard ADR recording the supersession.
What to look at. decision_log.md — Decision 10 (accepted, supersedes packing parts of Req 6.5/§7/9.2/9.9 + Phase 6 9.5)
WhyResolver.reason(for: TripPackingItem,…) and WhyDisclosure.Style.packing have no production caller after this change but are kept. They are pure additions on the shared explainability layer the task surface still depends on, and retain test coverage (WhyResolverPackingTests, WhyResolverParticipantHideTests, WhyDisclosureStyleTests). Removing them would churn the shared resolver for no behavioural gain.
Phase 3's task explainability is still wanted. Keeping the change packing-only minimises blast radius and avoids re-touching shipped task code and its tests.
The source-of-truth UI doc references packing long-press explainability in ~6 places. A single authoritative “superseded for packing (Decision 10)” note at the Explainability section header avoids the inconsistency risk of editing scattered prose while still preventing re-seeding.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | specs/phase-4-packing-sheet/decision_log.md | Removal overturns accepted Phase 4 (Req 6.5 / §7 / 9.2 / 9.9) and Phase 6 (Req 8.5 / 9.5) requirements with no decision-log entry — a project-convention gap. | Added Decision 10 (Enhanced Nygard ADR) recording the supersession, rationale, alternatives, and consequences. |
| minor | docs/agent-notes/accessibility.md | Note still claimed PackingItemRow exposes the 'Why is this here?' action and listed it in the long-press haptics row — now factually wrong. | Scoped the action and haptic to TaskRow; noted packing no longer exposes it. |
| minor | PackingItemRow.swift | handleInlineEditorVisibility decayed into a one-line pass-through after the disclosure-closing branch was removed. | Inlined into .onChange(of: isAnyInlineEditorOpen); updated the isAnyInlineEditorOpen doc comment's dangling cross-reference. |
| minor | PackingSheet.swift | Stale doc comment: 'Owns its inner state — disclosure id, manual-add form presentation'. | Updated to 'active inline-add-field id, manual-add form presentation'. |
| minor | UITestSeed.swift | Comment said the seeded master 'gives WhyDisclosureView a .ruleMatched reason to render' — packing no longer renders it. | Reworded to describe the rule-driven 'Toothbrush' backing item without the WhyDisclosure justification. |
| minor | CLAUDE.md + docs/scramble-ui-design-doc.md | Both still asserted packing uses WhyDisclosure (CLAUDE.md lines 7 & 112; UI doc Explainability section). | Adjusted CLAUDE.md to 'task-only since Decision 10'; added a superseded-for-packing note at the UI doc Explainability section. |
| nit | WhyResolver / WhyDisclosure.Style | Packing resolver overload and .packing style now have no production caller. | Intentionally retained (shared layer + test coverage); documented in packing-sheet.md and Decision 10. Not dead code to remove. |
| nit | PackingItemRow / PackingSheet efficiency | Efficiency agent: confirm no equivalent work was left behind or relocated to a hot path. | Verified net win — per-row resolver fetches eliminated; item.subItems still decoded once in body and passed down; no new per-render work. |
Click to expand.
diff --git a/Scramble/Scramble/Components/PackingItemRow.swift b/Scramble/Scramble/Components/PackingItemRow.swiftindex eb9b875..b282d3f 100644--- a/Scramble/Scramble/Components/PackingItemRow.swift+++ b/Scramble/Scramble/Components/PackingItemRow.swift@@ -1,4 +1,3 @@-import SwiftData import SwiftUI #if canImport(UIKit)@@ -70,23 +69,18 @@ enum SheetGroup: Sendable { /// Single row inside the `PackingSheet`. Mirrors `TaskRow`'s structure with /// packing-specific differences: the trailing avatar is replaced by an inline-/// Skip / Restore button (or nothing for read-only / repack groups), the-/// checkbox follows the pack vs repack colour rules in the UI doc, and the-/// `WhyDisclosureView` renders with `.packing(personColour:)` style.+/// Skip / Restore button (or nothing for read-only / repack groups), and the+/// checkbox follows the pack vs repack colour rules in the UI doc. ///-/// Long-press is spatially constrained to the name + tags region so it does-/// not overlap with the trailing context menu (Req 6.5). Read-only rows-/// (`notBringing` / `leftBehind`) keep the long-press for `WhyDisclosure` per-/// Req 7.10 but expose no checkbox toggle, no Skip/Restore, and no Edit.+/// Read-only rows (`notBringing` / `leftBehind`) expose no checkbox toggle, no+/// Skip/Restore, and no Edit. struct PackingItemRow: View { let item: TripPackingItem let group: SheetGroup let mode: PackingMode let personColour: Color- let isDisclosureOpen: Bool let onToggleState: () -> Void let onSkipOrRestore: () -> Void- let onLongPress: () -> Void let onEdit: () -> Void /// Saves the inline-edited note (raw text). Wired to /// `PackingItemGroup.saveNote` — the note glyph edits inline rather than via@@ -101,11 +95,8 @@ struct PackingItemRow: View { /// dismiss tap-catcher. let onAddFieldVisibilityChanged: (Bool) -> Void - @Environment(\.modelContext) private var modelContext @Environment(\.theme) private var theme @Environment(\.colorScheme) private var colorScheme- @Environment(\.isParticipantViewingSharedTrip) private var isParticipantViewingSharedTrip- @State private var resolvedReason: WhyDisclosure.Reason? /// Drives the inline add field in `PackingSubItemsView`, toggled by the /// sub-item (list) glyph in the trailing controls (left of Skip). @State private var isAddingSubItem = false@@ -114,9 +105,9 @@ struct PackingItemRow: View { @State private var isEditingNote = false /// True while either inline editor (note or sub-item) is showing. Reported to- /// the sheet as a single source of truth so a direct switch between the two- /// editors (one binding false, the other true in the same update) never- /// momentarily reads as "closed" — see `handleInlineEditorVisibility`.+ /// the sheet (via the `.onChange` below) as a single source of truth so a+ /// direct switch between the two editors (one binding false, the other true in+ /// the same update) never momentarily reads as "closed". private var isAnyInlineEditorOpen: Bool { isAddingSubItem || isEditingNote } var body: some View {@@ -170,49 +161,23 @@ struct PackingItemRow: View { #if DEBUG .accessibilityIdentifier("packingSheet.itemRow.\(item.name)") #endif+ // Forward either inline editor's visibility (note editor or sub-item add+ // field) to the sheet so it can mount the dismiss tap-catcher+ // (design § "Focus / keyboard / dismissal"). .onChange(of: isAnyInlineEditorOpen) { _, open in- handleInlineEditorVisibility(open)- }- .onChange(of: isDisclosureOpen) { _, open in- if open {- resolvedReason = WhyResolver.reason(- for: item,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- )- } else {- resolvedReason = nil- }- }- .onChange(of: item.trip?.attributesData) { _, _ in- if isDisclosureOpen {- resolvedReason = WhyResolver.reason(- for: item,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- )- }- }- .onChange(of: item.currentlyMatchesRules) { _, _ in- if isDisclosureOpen {- resolvedReason = WhyResolver.reason(- for: item,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- )- }+ onAddFieldVisibilityChanged(open) } } // MARK: - Subviews - /// Checkbox + name + `WhyDisclosure` + trailing action. This is the single- /// combined accessibility "row" element (name + state + owner + note in its- /// label) carrying the row-level custom actions, including the new- /// **Add sub-item** action (Req 8.2). The sub-item list is a sibling- /// `.contain` container rendered by `PackingSubItemsView` so its entries stay- /// individually addressable — the previous flat `.combine` over the whole- /// row could not express that.+ /// Checkbox + name + trailing action. This is the single combined+ /// accessibility "row" element (name + state + owner + note in its label)+ /// carrying the row-level custom actions, including the new **Add sub-item**+ /// action (Req 8.2). The sub-item list is a sibling `.contain` container+ /// rendered by `PackingSubItemsView` so its entries stay individually+ /// addressable — the previous flat `.combine` over the whole row could not+ /// express that. @ViewBuilder private func rowContent(variant: ThemeVariant, note: String?, subItems: [String]) -> some View { // Derive once from the values the body already read — `item.subItems`@@ -229,16 +194,6 @@ struct PackingItemRow: View { } .accessibilityElement(children: .combine) .accessibilityLabel(combinedLabel(note: note))- .modifier(- WhyAccessibilityAction(- enabled: PackingItemRow.hasWhyJustification(- item: item,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- ),- onWhy: onLongPress- )- ) .modifier(EditAccessibilityAction(enabled: !group.isReadOnly, onEdit: onEdit)) .modifier( SkipRestoreAccessibilityAction(@@ -254,31 +209,14 @@ struct PackingItemRow: View { ) } - /// Item name + (when open) the `WhyDisclosure`. The name is given a 44pt- /// min-height so its vertical centre lines up with the checkbox and trailing- /// glyphs while the row HStack stays top-aligned (disclosure flows beneath).+ /// Item name. Given a 44pt min-height so its vertical centre lines up with the+ /// checkbox and trailing glyphs while the row HStack stays top-aligned. @ViewBuilder private func nameColumn(variant: ThemeVariant) -> some View {- VStack(alignment: .leading, spacing: 6) {- Text(item.name)- .font(.body)- .foregroundStyle(group.isReadOnly ? variant.textSecondary : variant.textPrimary)- .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)- .contentShape(Rectangle())- .onLongPressGesture(minimumDuration: 0.4) {- #if canImport(UIKit)- UIImpactFeedbackGenerator(style: .light).impactOccurred()- #endif- onLongPress()- }-- if isDisclosureOpen, let reason = resolvedReason {- WhyDisclosureView(reason: reason, style: .packing(personColour: personColour))- #if DEBUG- .accessibilityIdentifier("packingSheet.whyDisclosure.\(item.name)")- #endif- }- }+ Text(item.name)+ .font(.body)+ .foregroundStyle(group.isReadOnly ? variant.textSecondary : variant.textPrimary)+ .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) } @ViewBuilder@@ -422,17 +360,6 @@ struct PackingItemRow: View { #endif } - /// Forwards either inline editor's visibility (note editor or sub-item add- /// field) to the sheet so it can mount the dismiss tap-catcher, and closes- /// this row's `WhyDisclosure` when an editor reveals — one inline expansion- /// at a time (design § "Focus / keyboard / dismissal").- private func handleInlineEditorVisibility(_ visible: Bool) {- if visible && isDisclosureOpen {- onLongPress() // toggles the open disclosure closed- }- onAddFieldVisibilityChanged(visible)- }- // MARK: - Visual state derivation /// Whether the row's checkbox is rendered in the "checked" state. Items@@ -532,18 +459,6 @@ struct PackingItemRow: View { } } - // Phase 6 Req 9.5 — "Why is this here?" gate, mirrors the long-press- // disclosure's resolved-reason check.- static func hasWhyJustification(- item: TripPackingItem,- context: ModelContext,- hideOnUnresolvedMaster: Bool- ) -> Bool {- WhyResolver.reason(- for: item, context: context, hideOnUnresolvedMaster: hideOnUnresolvedMaster- ) != nil- }- /// Flat 0.5 dimming for unmatched-non-pinned rows per Req 3.9 / 4.8. /// Single multiplier — chained `.opacity()` modifiers compose /// multiplicatively, which would overshoot the design intent.@@ -570,21 +485,6 @@ private struct EditAccessibilityAction: ViewModifier { } } -/// Phase 6 Req 9.5 — exposes the "Why is this here?" custom action only-/// when the underlying item has a non-nil `WhyResolver.reason(...)`.-private struct WhyAccessibilityAction: ViewModifier {- let enabled: Bool- let onWhy: () -> Void-- func body(content: Content) -> some View {- if enabled {- content.accessibilityAction(named: Text("Why is this here?")) { onWhy() }- } else {- content- }- }-}- private struct SkipRestoreAccessibilityAction: ViewModifier { let label: String? let onAction: () -> Void
diff --git a/Scramble/Scramble/Features/Trips/PackingSheet.swift b/Scramble/Scramble/Features/Trips/PackingSheet.swiftindex 42cf258..3173dee 100644--- a/Scramble/Scramble/Features/Trips/PackingSheet.swift+++ b/Scramble/Scramble/Features/Trips/PackingSheet.swift@@ -29,8 +29,8 @@ struct PackingSheetState: Identifiable { /// Per-person packing surface presented from a participant row inside the /// Departure (`pack`) or Day-before-return (`repack`) phase. Owns its inner-/// state — disclosure id, manual-add form presentation — and dismisses when-/// the bound person disappears from `trip.participants` (Req 2.8).+/// state — active inline-add-field id, manual-add form presentation — and+/// dismisses when the bound person disappears from `trip.participants` (Req 2.8). struct PackingSheet: View { let trip: Trip let person: Person@@ -41,7 +41,6 @@ struct PackingSheet: View { @Environment(\.theme) private var theme @Environment(\.colorScheme) private var colorScheme - @State private var openDisclosureItemID: UUID? /// Identity of the row whose inline sub-item add field is currently /// revealed, so the background tap-catcher can be mounted to dismiss it /// (design § "Focus / keyboard / dismissal"). Reported by `PackingItemRow`.@@ -77,7 +76,7 @@ struct PackingSheet: View { mode: mode, personColour: personColour, headerFocused: $headerFocused,- onClose: handleClose+ onClose: onDismiss ) #if DEBUG .accessibilityIdentifier("packingSheet.header")@@ -89,7 +88,6 @@ struct PackingSheet: View { group: group, mode: mode, personColour: personColour,- openDisclosureItemID: $openDisclosureItemID, activeAddFieldItemID: $activeAddFieldItemID, onEdit: { item in pendingForm = .edit(item: item)@@ -121,16 +119,15 @@ struct PackingSheet: View { } } .background {- // Disclosure / add-field dismiss tap target, mounted only when a- // disclosure or an inline add field is open. Lives on the background- // so it can't intercept taps destined for checkboxes, action buttons,- // or the close button. Ending editing makes the active add field lose- // focus, which collapses it (design § "Focus / keyboard / dismissal").- if openDisclosureItemID != nil || activeAddFieldItemID != nil {+ // Add-field dismiss tap target, mounted only when an inline add field+ // is open. Lives on the background so it can't intercept taps destined+ // for checkboxes, action buttons, or the close button. Ending editing+ // makes the active add field lose focus, which collapses it+ // (design § "Focus / keyboard / dismissal").+ if activeAddFieldItemID != nil { Color.clear .contentShape(Rectangle()) .onTapGesture {- openDisclosureItemID = nil activeAddFieldItemID = nil endTextEditing() }@@ -163,14 +160,6 @@ struct PackingSheet: View { } } - private func handleClose() {- if openDisclosureItemID != nil {- openDisclosureItemID = nil- } else {- onDismiss()- }- }- /// Resigns first responder app-wide so the inline add field loses focus and /// self-collapses. No-op off UIKit. private func endTextEditing() {@@ -264,7 +253,6 @@ private struct PackingItemGroup: View { let group: SheetGroup let mode: PackingMode let personColour: Color- @Binding var openDisclosureItemID: UUID? @Binding var activeAddFieldItemID: UUID? let onEdit: (TripPackingItem) -> Void @@ -290,10 +278,8 @@ private struct PackingItemGroup: View { group: group, mode: mode, personColour: personColour,- isDisclosureOpen: openDisclosureItemID == item.id, onToggleState: { toggleState(item) }, onSkipOrRestore: { skipOrRestore(item) },- onLongPress: { toggleDisclosure(item) }, onEdit: { onEdit(item) }, onSaveNote: { raw in saveNote(item, raw) }, onAddSubItem: { raw in addSubItem(item, raw) },@@ -345,10 +331,6 @@ private struct PackingItemGroup: View { save("skipOrRestore") } - private func toggleDisclosure(_ item: TripPackingItem) {- openDisclosureItemID = (openDisclosureItemID == item.id) ? nil : item.id- }- /// Appends a sub-item via the pure `PackingSubItems` helper, then commits /// through the same `hook.commit` chokepoint as the checkbox/skip /// mutations. The cap / empty guards reject *before* any write (Decision 9),
diff --git a/Scramble/Scramble/Persistence/UITestSeed.swift b/Scramble/Scramble/Persistence/UITestSeed.swiftindex 98530af..81267fb 100644--- a/Scramble/Scramble/Persistence/UITestSeed.swift+++ b/Scramble/Scramble/Persistence/UITestSeed.swift@@ -372,8 +372,7 @@ context.insert(sam) trip.participants = (trip.participants ?? []) + [sam] - // Master item used by the rule-driven "Toothbrush" entry; gives- // `WhyDisclosureView` a `.ruleMatched` reason to render.+ // Master item backing the rule-driven "Toothbrush" entry (`source: .rule`). let masterToothbrush = MasterPackingItem( name: "Toothbrush", person: arjen,
diff --git a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swiftindex 122119d..895b701 100644--- a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift+++ b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift@@ -4,8 +4,7 @@ import Testing @testable import Scramble -/// Phase 6 Req 9.3 + 9.5 — `PackingItemRow.composedAccessibilityLabel`-/// and the gated "Why is this here?" custom action.+/// Phase 6 Req 9.3 — `PackingItemRow.composedAccessibilityLabel`. @Suite("PackingItemRow accessibility", .serialized) @MainActor struct PackingItemRowAccessibilityTests {@@ -89,40 +88,6 @@ struct PackingItemRowAccessibilityTests { #expect(label == "Socks, not packed") } - // MARK: - Why action gate (Req 9.5)-- @Test("Manual one-off item exposes the Why action")- func manualHasWhy() throws {- let setup = try Self.makeSetup()- let trip = Trip(name: "T", startDate: .now, endDate: .now)- setup.context.insert(trip)- let item = TripPackingItem(trip: trip, name: "Manual", source: .manual)- setup.context.insert(item)- try setup.context.save()-- let hasWhy = PackingItemRow.hasWhyJustification(- item: item, context: setup.context, hideOnUnresolvedMaster: false- )- #expect(hasWhy)- }-- @Test("Participant-side unresolved-master rule item hides the Why action")- func participantUnresolvedHidesWhy() throws {- let setup = try Self.makeSetup()- let trip = Trip(name: "T", startDate: .now, endDate: .now)- setup.context.insert(trip)- let item = TripPackingItem(- trip: trip, masterItemID: UUID(), name: "Rule", source: .rule- )- setup.context.insert(item)- try setup.context.save()-- let hasWhy = PackingItemRow.hasWhyJustification(- item: item, context: setup.context, hideOnUnresolvedMaster: true- )- #expect(!hasWhy)- }- // MARK: - Helpers struct Setup {
diff --git a/Scramble/ScrambleUITests/PackingSheetUITests.swift b/Scramble/ScrambleUITests/PackingSheetUITests.swiftindex 22293a5..7a292b5 100644--- a/Scramble/ScrambleUITests/PackingSheetUITests.swift+++ b/Scramble/ScrambleUITests/PackingSheetUITests.swift@@ -258,33 +258,6 @@ final class PackingSheetUITests: XCTestCase { ) } - @MainActor- func testLeftBehindRowLongPressShowsWhy() {- let app = launchedApp(fixture: "phase4-repack-mode-trip")- openTripDetail(app, tripName: "Mountain Trip")-- openPackingSheetForFirstParticipant(app)-- let header = app.descendants(matching: .any)- .matching(identifier: "packingSheet.header")- .firstMatch- XCTAssertTrue(header.waitForExistence(timeout: 5))-- let row = app.descendants(matching: .any)- .matching(identifier: "packingSheet.itemRow.Boots")- .firstMatch- XCTAssertTrue(row.waitForExistence(timeout: 5))- row.press(forDuration: 0.6)-- let disclosure = app.descendants(matching: .any)- .matching(identifier: "packingSheet.whyDisclosure.Boots")- .firstMatch- XCTAssertTrue(- disclosure.waitForExistence(timeout: 3),- "Long-press on a left-behind row should still expose WhyDisclosure (Req 7.10)"- )- }- // MARK: - 5. Manual item creation @MainActor@@ -362,43 +335,6 @@ final class PackingSheetUITests: XCTestCase { ) } - // MARK: - 7. WhyDisclosure-- @MainActor- func testWhyDisclosurePackingMatched() {- let app = launchedApp(fixture: "phase4-pack-mode-trip")- openTripDetail(app, tripName: "Beach Trip")-- openPackingSheetForFirstParticipant(app)-- let header = app.descendants(matching: .any)- .matching(identifier: "packingSheet.header")- .firstMatch- XCTAssertTrue(header.waitForExistence(timeout: 5))-- // Toothbrush is rule-driven (`source: .rule`) with conditions: .always.- let row = app.descendants(matching: .any)- .matching(identifier: "packingSheet.itemRow.Toothbrush")- .firstMatch- XCTAssertTrue(row.waitForExistence(timeout: 5))- row.press(forDuration: 0.6)-- let disclosure = app.descendants(matching: .any)- .matching(identifier: "packingSheet.whyDisclosure.Toothbrush")- .firstMatch- XCTAssertTrue(- disclosure.waitForExistence(timeout: 3),- "Long-press on a rule-driven item should expand the WhyDisclosure"- )- }-- @MainActor- func testWhyDisclosurePersonColouredBackground() throws {- throw XCTSkip(- "Requires colour-key debug marker; can be added when accessibility-coloured-marker pattern formalised"- )- }- // MARK: - 8. Sheet body change propagation @MainActor
diff --git a/docs/agent-notes/accessibility.md b/docs/agent-notes/accessibility.mdindex 734ed51..0135627 100644--- a/docs/agent-notes/accessibility.md+++ b/docs/agent-notes/accessibility.md@@ -21,8 +21,10 @@ Conventions used by Phase 6 polish: - `PackingItemRow` — combined label includes item name + current `PackingState` + owning person name. Excluded items labelled `"not bringing"`; repack-mode Left Behind items labelled- `"left behind"`. Same `"Why is this here?"` custom action, same- gating.+ `"left behind"`. Packing rows do **not** expose a `"Why is this here?"`+ action — the long-press explainability surface was removed from the+ packing sheet (see "Packing WhyDisclosure removed" in `packing-sheet.md`+ and phase-4 `decision_log.md` Decision 10). The action remains on `TaskRow`. - Per-person packing progress bar in `PackingSummarySection` — `accessibilityValue` reads `"{name}'s packing, {packed} of {total} packed"`.@@ -30,10 +32,11 @@ Conventions used by Phase 6 polish: because the destination is already part of the spoken trip name (Req 9.6). -`Why is this here?` accessibility action: presence is gated on the same-`WhyResolver.reason(...)` check the long-press uses. Rows whose items-have no rule justification (manual one-offs, items whose master was-deleted under certain conditions) do not expose the action.+`Why is this here?` accessibility action (`TaskRow` only): presence is+gated on the same `WhyResolver.reason(...)` check the long-press uses.+Tasks with no rule justification (manual one-offs, items whose master was+deleted under certain conditions) do not expose the action. Packing rows+no longer expose this action (long-press explainability removed). ## Dynamic Type @@ -66,7 +69,7 @@ Five interactions fire haptics (Reqs 8.1–8.5): | `PackingItemRow` skip / restore | `.light` | `Components/PackingItemRow.swift` | | `PhaseRow` tap (expand/collapse) | `.medium` | `Components/PhaseRow.swift` | | `PackingSheet` root `.onAppear` | `.soft` | `Features/Trips/PackingSheet.swift` |-| `WhyDisclosure` becoming visible (long-press) | `.light` | `Components/TaskRow.swift` / `PackingItemRow.swift` |+| `WhyDisclosure` becoming visible (long-press) | `.light` | `Components/TaskRow.swift` | All use `UIImpactFeedbackGenerator(style:)` per Req 8.6. iOS's native "reduce haptics" preference suppresses them automatically.
diff --git a/docs/agent-notes/packing-sheet.md b/docs/agent-notes/packing-sheet.mdindex ac8f6ff..e26582a 100644--- a/docs/agent-notes/packing-sheet.md+++ b/docs/agent-notes/packing-sheet.md@@ -44,6 +44,20 @@ Focus restoration on auto-dismiss runs from `TripDetailView.handlePackingSheetDi `PackingSheet.body.task` runs `try? await Task.sleep(for: .milliseconds(500))` before setting `headerFocused = true`. Setting the focus inside `.onAppear` is too early on real devices — the a11y frame for the sheet has not been built yet and the focus binding silently fails. 500ms matches Apple's WWDC guidance for cross-context VoiceOver focus handoff. +## Packing WhyDisclosure removed (long-press)++Packing rows **no longer** surface the "why is this here?" explainability panel. The long-press gesture, the inline `WhyDisclosureView`, the `openDisclosureItemID` sheet state + background dismiss-tap target, and the VoiceOver "Why is this here?" custom action were all removed from `PackingItemRow` / `PackingSheet` — the owner found it unused and the long-press made the row interaction noisy. This is a deliberate divergence from phase-4 Req 6.5 / 7.10 and phase-6 Req 9.5 (which were packing-specific).++Consequences:+- `WhyDisclosureView` and `WhyResolver` still exist — the **task** surface (`TaskRow` / `TaskListSection`) keeps long-press WhyDisclosure unchanged.+- `WhyResolver.reason(for: TripPackingItem, …)` now has no production caller; it stays in place (and is still covered by `WhyResolverPackingTests` / `WhyResolverParticipantHideTests`) rather than being torn out, since it's a pure function and removing it would churn the shared resolver the task path depends on. The `WhyDisclosure.Style.packing(personColour:)` case is likewise now test-only.++## Gotcha: `PackingItemRowAccessibilityTests` crashes when run in isolation++`PackingItemRowAccessibilityTests` (and likely other `@MainActor .serialized` suites that build a fresh `ModelContainer(for: SchemaV3)` per test) **crashes the xctest process when run via a narrow `-only-testing` selection** — every test reports `failed (0.000 seconds)` and the suite re-runs (xcodebuild relaunches after a crash). This is **pre-existing and not a logic failure**: an individual test (`-only-testing:…/unownedLabel`) passes, and the full `make test-quick` (whole `ScrambleTests` target) passes too. The crash is the repeated CloudKit-schema `ModelContainer` creation issue (see `rules/language-rules/swift.md` → "Shared ModelContainer Initialization in Tests").++Practical consequence: **verify these suites with the full `make test-quick`, not a hand-picked `-only-testing` subset.** A green/red from a small subset run is unreliable for the container-creating suites. (Confirmed by stashing all changes and reproducing the identical crash on unmodified `main`.)+ ## WhyDisclosure `Style` migration `WhyDisclosureView` migrated from `init(reason:, phaseColour:)` to `init(reason:, style:)` where `style` is a `WhyDisclosure.Style` enum with `.tasks(phaseColour:)` and `.packing(personColour:)` cases. The case resolves internally to `(tint: Color, backgroundOpacity: Double, borderOpacity: Double?)`:
diff --git a/docs/scramble-ui-design-doc.md b/docs/scramble-ui-design-doc.mdindex 8bd6cc4..6925567 100644--- a/docs/scramble-ui-design-doc.md+++ b/docs/scramble-ui-design-doc.md@@ -173,6 +173,8 @@ Forking pack and repack into divergent UIs is a deliberate non-goal — the cons ## Explainability — “Why is this here?” +> **Superseded for packing (phase-4 Decision 10):** the long-press explainability described in this section was **removed from the packing sheet** — it now applies to **tasks only** (`TaskRow`). The packing-specific paragraphs below (packing long-press, the “Packing context” visual treatment, and the packing rotor action) are retained as historical design intent but no longer ship. See `specs/phase-4-packing-sheet/decision_log.md` Decision 10.+ Every task and packing item supports a long-press gesture to reveal why it appears on this trip. There is no dedicated visual affordance — the gesture is discoverable through standard iOS conventions (long-press for context). This keeps row layouts uncluttered. ### Behaviour
diff --git a/specs/phase-4-packing-sheet/decision_log.md b/specs/phase-4-packing-sheet/decision_log.mdindex e49fc87..58f50fa 100644--- a/specs/phase-4-packing-sheet/decision_log.md+++ b/specs/phase-4-packing-sheet/decision_log.md@@ -293,3 +293,44 @@ Nested sheets are the iOS-standard pattern for a focused secondary task. `Naviga - Two-sheet stack is a known iOS UI complexity (e.g., swipe-down can target the inner or outer sheet depending on framework version); accept and validate during testing. ---++## Decision 10: Remove the WhyDisclosure long-press explainability from the packing sheet++**Date**: 2026-06-26+**Status**: accepted — supersedes the packing-side parts of Req 6.1 / 6.5 / 7.1–7.10 / 9.2 / 9.9 (this spec) and Phase 6 Req 8.5 / 9.5 (the `PackingItemRow` carve-out only)++### Context++Phase 4 Section 7 specified a `WhyDisclosure` explainability panel for packing items, revealed by a long-press on the item name (Req 6.5) and mirrored by a `"Why is this here?"` VoiceOver custom action (Req 9.2, hardened in Phase 6 Req 9.5). In practice the owner found the packing-side surface unused, and the long-press made the (already action-dense) packing rows feel noisy — every name-region press armed a gesture, and the panel most often resolved to a low-value reason. The packing masters frequently fail to resolve from the trip's local context, so the panel often showed only the generic "added by a rule that has since been removed" message regardless of the item.++### Decision++Remove the long-press `WhyDisclosure`, its inline panel, the `"Why is this here?"` VoiceOver custom action, and the supporting per-row state/fetches from `PackingItemRow` / `PackingSheet`. The task-side explainability (`TaskRow` / `TaskListSection`, Phase 3) is unchanged. The shared `WhyResolver` and `WhyDisclosureView` stay in place — they are still used by the task surface; their `TripPackingItem` overload and the `WhyDisclosure.Style.packing` case become test-only but are retained rather than torn out (removing them would churn the shared resolver for no behavioural gain and lose existing test coverage).++### Rationale++The feature was a confirmed product cost (annoying gesture) with little realised benefit on the packing side. Removing it also deletes per-row SwiftData fetches that ran on accessibility-action setup and on every disclosure-open / attribute-change / rules-change event — a net efficiency win on the packing sheet. Scoping the removal to packing (leaving tasks intact) keeps the change small and avoids touching Phase 3's shipped, still-wanted task explainability.++### Alternatives Considered++- **Keep the VoiceOver action, drop only the long-press**: Rejected — the action exposes the same panel; keeping it is inconsistent with the removed visual affordance and still pays the per-row resolver fetch.+- **Fix the underlying master-resolution bug instead of removing**: Rejected — the owner does not want the surface at all on packing, independent of the bug.+- **Replace long-press with a tap affordance (e.g., a "?" glyph)**: Rejected — adds another glyph to an already-dense row for a feature the owner does not use.++### Consequences++**Positive:**+- Simpler, quieter packing rows; the name region no longer arms a gesture.+- Eliminates per-row `WhyResolver.reason(...)` fetches on the packing sheet.+- Task explainability is untouched.++**Negative:**+- Packing items lose in-app "why is this here?" explainability (sighted and VoiceOver). Accepted per owner request.+- `WhyResolver.reason(for: TripPackingItem,…)` and `WhyDisclosure.Style.packing` now have no production caller (test-only); a future reader must not mistake them for dead code — see `docs/agent-notes/packing-sheet.md`.+- The source-of-truth UI doc (`docs/scramble-ui-design-doc.md`) still describes packing long-press explainability in places; this ADR is the authoritative override.++### Impact++`Scramble/Scramble/Components/PackingItemRow.swift`, `Scramble/Scramble/Features/Trips/PackingSheet.swift`; removed tests in `PackingItemRowAccessibilityTests.swift` and `PackingSheetUITests.swift`; notes updated in `docs/agent-notes/packing-sheet.md` and `docs/agent-notes/accessibility.md`.++---
diff --git a/CLAUDE.md b/CLAUDE.mdindex 1823aa2..d0e26fe 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project status -Scramble is a native iOS app (macOS planned later) for trip planning, packing, and shared family coordination via CloudKit. Phases 1–6 have landed: SwiftData model + CloudKit-aware container, Theme system + Midnight Atlas variants, two-tab app shell, Trip CRUD with inline person creation, deterministic rules engine + Master Lists UI, timeline accordion + Trip-level Tasks, the per-person Packing Sheet with pack / repack modes and the `WhyDisclosure` explainability surface, Phase 5's CloudKit sharing infrastructure — `SchemaV3` with `TripPersonSnapshot` / `TripZoneState` / `MigrationJournalEntry`, dual SwiftData containers (globals + tripsLocal), Stage A + Stage B migration pipeline, `TripSyncEngine` over two `CKSyncEngine` instances, `CloudKitSharingService` + `UICloudSharingController` wrapper, the Share toolbar button and Trip Detail Participants section, silent-push routing, and the release-prep CloudKit-schema-promotion checklist. Phase 5.1 then routed Trip CRUD through `tripsLocal` so the sharing pipeline actually carries edits — container topology + chokepoint extension (`LocalWriteHook.commitDeletion`) + view-layer read/write conversion to `TripPersonSnapshot` + the 15-step `ZoneMigrationCoordinator` relocation algorithm with `TripSyncEventBus` multicast and `SignInResumeCoordinator` storm-collapse all landed. Phase 6 added local activation notifications (`Trip.countryCode` on `SchemaV3` — a post-Phase-6 bugfix collapsed the short-lived `SchemaV4` back into V3 because a property-only addition to a shared top-level class can't be a distinct schema version; see `persistence.md`, `NotificationIdentifier`/`NotificationPlanner`/`NotificationReconciler` pure primitives, `NotificationsService` orchestrator wired through `PendingChangeBroadcaster` over `LocalWriteHook`, `NotificationRouter` for taps + the `scramble://` URL scheme, `RootView` tap consumption), the deferred Trip Detail flag emoji, and the polish pass (shared `Animation.scrambleStandard`, haptics matrix, accessibility custom actions). See `specs/phase-1-foundation/` through `specs/phase-6-notifications-polish/` for what shipped and `docs/agent-notes/` (`persistence.md`, `rules-engine.md`, `packing-sheet.md`, `sync-infrastructure.md`, `phase-5-ui-surfaces.md`, `notifications.md`, `accessibility.md`) for load-bearing implementation notes.+Scramble is a native iOS app (macOS planned later) for trip planning, packing, and shared family coordination via CloudKit. Phases 1–6 have landed: SwiftData model + CloudKit-aware container, Theme system + Midnight Atlas variants, two-tab app shell, Trip CRUD with inline person creation, deterministic rules engine + Master Lists UI, timeline accordion + Trip-level Tasks, the per-person Packing Sheet with pack / repack modes and the `WhyDisclosure` explainability surface (later removed from packing — task-only since phase-4 Decision 10), Phase 5's CloudKit sharing infrastructure — `SchemaV3` with `TripPersonSnapshot` / `TripZoneState` / `MigrationJournalEntry`, dual SwiftData containers (globals + tripsLocal), Stage A + Stage B migration pipeline, `TripSyncEngine` over two `CKSyncEngine` instances, `CloudKitSharingService` + `UICloudSharingController` wrapper, the Share toolbar button and Trip Detail Participants section, silent-push routing, and the release-prep CloudKit-schema-promotion checklist. Phase 5.1 then routed Trip CRUD through `tripsLocal` so the sharing pipeline actually carries edits — container topology + chokepoint extension (`LocalWriteHook.commitDeletion`) + view-layer read/write conversion to `TripPersonSnapshot` + the 15-step `ZoneMigrationCoordinator` relocation algorithm with `TripSyncEventBus` multicast and `SignInResumeCoordinator` storm-collapse all landed. Phase 6 added local activation notifications (`Trip.countryCode` on `SchemaV3` — a post-Phase-6 bugfix collapsed the short-lived `SchemaV4` back into V3 because a property-only addition to a shared top-level class can't be a distinct schema version; see `persistence.md`, `NotificationIdentifier`/`NotificationPlanner`/`NotificationReconciler` pure primitives, `NotificationsService` orchestrator wired through `PendingChangeBroadcaster` over `LocalWriteHook`, `NotificationRouter` for taps + the `scramble://` URL scheme, `RootView` tap consumption), the deferred Trip Detail flag emoji, and the polish pass (shared `Animation.scrambleStandard`, haptics matrix, accessibility custom actions). See `specs/phase-1-foundation/` through `specs/phase-6-notifications-polish/` for what shipped and `docs/agent-notes/` (`persistence.md`, `rules-engine.md`, `packing-sheet.md`, `sync-infrastructure.md`, `phase-5-ui-surfaces.md`, `notifications.md`, `accessibility.md`) for load-bearing implementation notes. Stack: iOS 26+, Swift 6.0 language mode (Xcode 26 toolchain), SwiftUI, SwiftData, CloudKit (CKShare for per-trip sharing). Bundle ID `me.nore.ig.Scramble`, CloudKit container `iCloud.me.nore.ig.scramble`. Project lives at `Scramble/Scramble.xcodeproj` (nested folder, standard Xcode layout). @@ -109,7 +109,7 @@ From the UI design doc — follow this sequence when starting: 2. `PhaseNode` component (circle + spine line + glow states) 3. Timeline composition with one-at-a-time accordion 4. Task row + checkbox + assignee avatar-5. `WhyDisclosure` (used by tasks AND packing items)+5. `WhyDisclosure` (tasks only; removed from packing — see phase-4 Decision 10) 6. Packing summary block on Departure / Day-before-return phases 7. `PackingSheet` pack mode 8. `PackingSheet` repack mode (filter swap, read-only Left Behind group)
The earlier reported crash did not reproduce under a devicectl launch and no device crash report was generated; the app survives a force-kill. Because devicectl can relax the launch watchdog, a normal icon-tap cold launch is the real confirmation. The merge also pulled #11's schema/migration changes, which may itself have resolved a first-launch/migration issue.
This .serialized SwiftData suite crashes the xctest process under a narrow -only-testing selection (pre-existing, reproduced on unmodified main). Verify via the full make test-quick, not a hand-picked subset.