Removes the task-row WhyDisclosure explainability surface and deletes the orphaned Explainability/ subsystem — a subtractive change (+247 / −1504).
Explainability/ subsystem (WhyDisclosure, WhyResolver, ConditionsFormatter) + 5 test suites — the task row was its last production consumer after packing was stripped in phase-4 Decision 10.TaskRow sheds its ModelContext, the isParticipantViewingSharedTrip read, resolvedReason state + 4 onChange recomputes, and a per-row WhyResolver SwiftData fetch — a net timeline efficiency win.openDisclosureTaskID state chain is unthreaded from TripDetailView → AccordionTimeline → TaskListSection → TaskRow, including the dismiss-tap overlay.origin/main's T-1619 centred the task name with the checkbox; this branch collapsed that region. Fixed pre-emptively by re-adding minHeight: 44 to the bare Text (e998c60).isParticipantViewingSharedTrip is deliberately kept — still used by PackingSheet / PackingItemForm.Code-clean; rebase onto origin/main before pushing
The removal itself is complete, correct, and verified: build succeeds, swiftlint is clean (0 violations, 225 files), the four parallel review agents found no code defects, and the unit suite passes except two pre-existing, unrelated sharing/concurrency tests in untouched code.
The one substantive finding — a silent regression of T-1619's task-name centring on merge — was fixed in-review (e998c60). The remaining action is operational: this branch forked from 9855eab, but origin/main has since advanced to 84243bf (T-1619 + an independent copy of the same test-infra fix). Rebase/merge onto origin/main and re-run the suite on a healthy runner before pushing. The expected conflicts are trivially resolvable.
2f70ed2 T-1617: Add remove-task-why-disclosure smolspec ed39eb5 T-1617: Remove task WhyDisclosure and delete Explainability subsystem 4501714 T-1617: changelog for task WhyDisclosure removal 7598853 T-1617: add remove-task-why-disclosure to specs overview e998c60 T-1617: preserve T-1619 task-name centring after WhyDisclosure removal Scramble had a “Why is this here?” pop-up on each trip task — long-press a task and a panel explained why it was on your list. This removes that feature entirely, along with the behind-the-scenes code that produced the explanations (three files and their tests), because nothing else used it.
The pop-up was judged to add little and clutter the interface; the same thing was already removed from the packing list earlier. Task rows get simpler and slightly faster, and ~1,500 lines of unused code go away.
Which tasks appear does not change — the rules engine that decides your list is untouched. Only the on-demand “why” explanation is gone.
TaskRow.swift — removes the inline WhyDisclosureView, the long-press gesture + haptic, resolvedReason state and its four onChange recomputes, the hasWhyJustification helper, the “Why is this here?” VoiceOver action, and the resolver-only modelContext / isParticipantViewingSharedTrip reads. The name column collapses from a VStack to a bare Text.TaskListSection / AccordionTimeline / TripDetailView — unthread the openDisclosureTaskID binding, the dismiss-tap background, and the phase-toggle reset.Explainability/ (3 sources) + 5 test suites; trims 3 hasWhyJustification unit tests and 3 disclosure UI tests.ParticipantViewingEnvironmentKey (doc comment only) — the flag still gates packing behaviour.A subtractive change. Decision 1: once the task row (the last consumer) stops using the stack, it becomes dead code kept alive only by its own tests, so it is deleted rather than kept test-only — mirroring the packing-side removal (Decision 10).
Keeping it test-only was rejected as churn; keeping only the VoiceOver action was rejected as inconsistent; the design docs are intentionally not rewritten (the ADR is the override).
onChange(of: task.trip?.attributesData), which forced SwiftData observation of a serialized blob per row — and the per-row WhyResolver.reason(...) fetch inside the accessibility-actions builder narrows TaskRow's inputs to the model object + theme.openDisclosureTaskID: UUID? was owned by TripDetailView and bound through four hops; all are removed, plus the Color.clear dismiss overlay and the reset inside the animated phase-toggle block.e998c60). T-1619 (on main) added .frame(minHeight: isDisclosureOpen ? nil : 44) to centre a single-line name with the checkbox. This branch's bare Text had no minHeight; the row-level frame sizes the row, not the text. Re-added unconditionally (the disclosure gate is moot) as a separate modifier on the Text.makeContainer().mainContext, letting the container deallocate under the live context — the documented non-retained-ModelContainer anti-pattern that crashes the shared test host on the current runtime. Now retained.TaskRow no longer depends on a ModelContext. WhyResolver/ConditionsFormatter are deleted, not relocated; the rules engine's currentlyMatchesRules dimming is a separate, untouched concern. Decision 1 is the canonical override of the design docs' explainability sections.
Stale base: rebase onto origin/main (T-1619 + a duplicate of the container fix) before pushing; conflicts in TaskRow.swift (take this branch), the container helpers (either side), and CHANGELOG.md are trivial. Full make test is not green on the current degraded simulator, but the failures reproduce identically on the pre-change base — re-run on a healthy runner for a clean green.
Scramble/Scramble/Components/TaskRow.swift
Why it matters. The core user-visible change and the biggest dependency reduction: removes the long-press panel, the VoiceOver action, resolvedReason state + 4 onChange recomputes, and the ModelContext / isParticipantViewingSharedTrip reads.
What to look at. TaskRow.swift:23-70
Scramble/Scramble/Components/TaskRow.swift
Why it matters. Without this, merging onto origin/main silently reverts T-1619's just-shipped vertical-centring fix — single-line task names would drift above the checkbox again.
What to look at. TaskRow.swift:26-33
Scramble/Scramble/Features/Trips/TripDetailView.swift
Why it matters. Removes a four-hop @Binding (TripDetailView → AccordionTimeline → TaskListSection → TaskRow) plus a dismiss-tap overlay and a phase-toggle reset — dead state once the disclosure is gone.
What to look at. TripDetailView.swift / AccordionTimeline.swift / TaskListSection.swift
Scramble/Scramble/Explainability/WhyResolver.swift
Why it matters. ~1,000 lines of production + test code removed rather than kept as test-only dead code (Decision 1).
What to look at. Explainability/{WhyDisclosure,WhyResolver,ConditionsFormatter}.swift + 5 suites
Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift
Why it matters. Five helpers returned only a container's mainContext, letting the container deallocate and crashing the shared test host on the current runtime — required to run the suite at all.
What to look at. makeContainer() helpers across 5 test files
The task row was the last production consumer after packing was stripped in phase-4 Decision 10. Keeping WhyResolver/WhyDisclosureView/ConditionsFormatter as test-only code would maintain ~1,500 lines and 5 suites for code no screen renders. Decision 1 deletes the stack outright.
It has independent consumers: PackingSheet re-injects it across the container boundary and PackingItemForm uses it to make a master-derived item's category read-only for participants. Only its doc comment is updated.
Decision 1 is the authoritative override of the design docs' “explainability is computed on demand” / WhyDisclosure UI sections; the docs are left as historical record, matching the Decision 10 precedent. Stale mentions in CHANGELOG and historical phase specs are also intentionally retained.
Rather than leaving the merge to reintroduce minHeight: 44, the fix adds it to the collapsed Text now, so any merge resolution taking this branch's side preserves the centring. (Decision made during pre-push review, not by the original author.)
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | TaskRow.swift merge vs origin/main (T-1619) | Branch forked before T-1619 ("Centre task row name with its checkbox"), which added minHeight:44 to the task-name Text. This branch collapsed that region to a bare Text with no minHeight, so a naive merge would silently revert T-1619's centring. Confirmed via git merge-tree that TaskRow.swift conflicts. | Fixed in e998c60: re-added .frame(maxWidth:.infinity, minHeight: 44, alignment:.leading) to the collapsed Text (unconditional; the disclosure gate is gone). Build + lint verified. |
| minor | Container-retention test fix redundant with origin/main | The 5 ModelContainer-retention helper fixes are also present on origin/main (applied independently there). On rebase these produce trivial, semantically-identical conflicts. | Left as-is (reverting would break this branch's own tests on its base). Flagged as take-either-side on rebase. |
| minor | tasks.md task-4 verify bullet is over-broad | The verify note claims a tree-wide grep returns hits only inside specs/remove-task-why-disclosure/, which is literally false (CHANGELOG, design docs, and historical specs still contain the term). | Left as a historical task record; the actual intent (no stale references in active code/docs) is met. Cosmetic. |
| minor | Container-retention comment wording | The retain-the-container comment is phrased three different ways across the touched test helpers. | Skipped as cosmetic; these helpers are redundant with origin/main's copies anyway. |
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex ad13aed..20ab054 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -89,6 +89,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Removed +- `remove-task-why-disclosure` (T-1617) — Task-row `WhyDisclosure` explainability surface and the entire `Explainability/` subsystem. The long-press "Why is this here?" panel + its light-impact haptic, the matching VoiceOver custom action, and the `openDisclosureTaskID` state chain are gone from `TaskRow` and unthreaded from `TaskListSection` / `AccordionTimeline` / `TripDetailView` (including the dismiss-tap background layer and the phase-toggle reset). `TaskRow` no longer reads `modelContext` or `isParticipantViewingSharedTrip` and drops its `resolvedReason` cache + four `onChange` recomputes; the row collapses back to a bare name `Text`. Because the task row was the last production consumer after packing was stripped in phase-4 Decision 10, the whole shared stack is **deleted** rather than left as test-only dead code: `Scramble/Scramble/Explainability/{WhyDisclosure,WhyResolver,ConditionsFormatter}.swift` plus the five dedicated suites (`WhyDisclosureStyleTests`, `WhyResolverTests`, `WhyResolverPackingTests`, `WhyResolverParticipantHideTests`, `ConditionsFormatterTests`), the three `hasWhyJustification` cases from `TaskRowAccessibilityTests`, and the three disclosure UI tests from `TimelineAndTaskUITests`. The `isParticipantViewingSharedTrip` environment key is retained — it still has independent consumers in `PackingSheet` and `PackingItemForm` — with only its doc comment updated. Recorded as `specs/remove-task-why-disclosure/decision_log.md` Decision 1 (the authoritative override of the design docs' "explainability is computed on demand" / WhyDisclosure UI sections, following the Decision 10 precedent of not rewriting the design docs); `CLAUDE.md` and `docs/agent-notes/` (`accessibility.md`, `packing-sheet.md`, `rules-engine.md`, `phase-5-ui-surfaces.md`) updated to match. Also includes a pre-existing test-infra fix required to run the suite at all on the current Xcode/simulator runtime: five test helpers (`PackingItemRowAccessibilityTests`, `NotificationsServiceTests`, `RulesEngineRunnerTests`, the two model-bridge helpers, and `TaskRowAccessibilityTests`) now retain their `ModelContainer` instead of returning only its `mainContext` — the documented non-retained-container anti-pattern that crashed the test host.+ - Packing-sheet `WhyDisclosure` long-press explainability (packing rows only; task-side explainability is unchanged). The long-press gesture, the inline "Why is this here?" panel, and the matching VoiceOver custom action are gone from `PackingItemRow` / `PackingSheet`, along with the per-row `WhyResolver` fetches that backed them (a net efficiency win on the packing sheet). The shared `WhyResolver.reason(for:TripPackingItem)` overload and `WhyDisclosure.Style.packing` are now test-only but retained. Also untangles #11's inline note/sub-item editors from the removed disclosure (the editor-visibility handler no longer closes it). Divergence from accepted Phase 4 (Req 6.5 / §7 / 9.2 / 9.9) and Phase 6 (Req 8.5 / 9.5) requirements is recorded as `specs/phase-4-packing-sheet/decision_log.md` Decision 10; `docs/agent-notes/packing-sheet.md` + `accessibility.md`, `CLAUDE.md`, and `docs/scramble-ui-design-doc.md` updated to match. Removed the now-obsolete `hasWhyJustification` unit tests and packing `WhyDisclosure` UI tests. ### Fixed
diff --git a/CLAUDE.md b/CLAUDE.mdindex ac2e3a0..9c58f56 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 (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.+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 (the long-press "why is this here?" explainability surface was removed from packing in phase-4 Decision 10 and removed entirely — task rows plus the Explainability subsystem — in T-1617; see `specs/remove-task-why-disclosure/`), 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). @@ -59,9 +59,9 @@ This determinism is what prevents drift across devices. Do not introduce non-det Trip-level items hold `masterItemID: UUID?` (nil for one-offs) as a stable reference, but `name` is a **snapshot** copied at creation — not live-linked. Editing the master list does not retroactively rename items already on a trip. -### Explainability is computed on demand+### Explainability surface removed (T-1617) -Do not snapshot matched conditions at item creation. Compute on demand by intersecting the master item's current conditions with the trip's current attributes. Master conditions are stable references; trip attributes are the input.+The in-app "why is this here?" explainability surface (the long-press disclosure on task rows and its supporting subsystem) has been removed. See `specs/remove-task-why-disclosure/decision_log.md` Decision 1, which overrides the "explainability is computed on demand" sections of the design docs. Rule matching still drives which items appear and the `currentlyMatchesRules` dimming, but there is no longer any on-demand "why" computation or disclosure UI. ### Trip-level edits do not modify the master list @@ -109,11 +109,11 @@ 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` (tasks only; removed from packing — see phase-4 Decision 10)+5. ~~Explainability disclosure~~ — removed entirely in T-1617 (was tasks-only after phase-4 Decision 10); see `specs/remove-task-why-disclosure/` 6. Packing summary block on Day-before / Day-before-return phases 7. `PackingSheet` pack mode 8. `PackingSheet` repack mode (filter swap, read-only Left Behind group)-9. Rules engine: per-item matching condition exposure for explainability+9. Rules engine: per-item matching / diffing (the explainability exposure this once fed was removed in T-1617) 10. Polish: transitions, haptics, VoiceOver, Dynamic Type ## Open questions
diff --git a/Scramble/Scramble/Components/TaskListSection.swift b/Scramble/Scramble/Components/TaskListSection.swiftindex a1405b5..b8598e7 100644--- a/Scramble/Scramble/Components/TaskListSection.swift+++ b/Scramble/Scramble/Components/TaskListSection.swift@@ -6,15 +6,10 @@ import os /// `trip.tasks` to the phase, drops soft-deleted rule rows /// (`userDeletedOnThisTrip == true`), sorts via `TaskListHelpers.sorted`, and /// appends a dashed "Add task" affordance.-///-/// The single source of disclosure state for the timeline is the parent's-/// `openDisclosureTaskID` binding; this section forwards it to each-/// `TaskRow` and clears it when a different row's long-press fires. struct TaskListSection: View { let trip: Trip let phase: Phase let phaseColour: Color- @Binding var openDisclosureTaskID: UUID? let onAdd: () -> Void let onEdit: (TripTask) -> Void @@ -30,9 +25,7 @@ struct TaskListSection: View { TaskRow( task: task, phaseColour: phaseColour,- isDisclosureOpen: openDisclosureTaskID == task.id, onToggleComplete: { toggleComplete(task) },- onLongPress: { toggleDisclosure(task) }, onEdit: { onEdit(task) }, onDelete: { delete(task) } )@@ -44,17 +37,6 @@ struct TaskListSection: View { #endif } .padding(.top, 8)- .background {- // Disclosure-dismiss tap target. Lives on a background layer (behind- // task rows and the add button) and is only mounted when there is an- // open disclosure, so it can't intercept taps destined for the- // checkbox or `DashedAddButton` when no disclosure is open.- if openDisclosureTaskID != nil {- Color.clear- .contentShape(Rectangle())- .onTapGesture { openDisclosureTaskID = nil }- }- } #if DEBUG .accessibilityIdentifier("tripDetail.taskListSection.\(phase.rawValue)") #endif@@ -73,10 +55,6 @@ struct TaskListSection: View { } } - private func toggleDisclosure(_ task: TripTask) {- openDisclosureTaskID = (openDisclosureTaskID == task.id) ? nil : task.id- }- private func delete(_ task: TripTask) { if task.source == .manual { modelContext.delete(task)
diff --git a/Scramble/Scramble/Components/TaskRow.swift b/Scramble/Scramble/Components/TaskRow.swiftindex 7da706a..25726e1 100644--- a/Scramble/Scramble/Components/TaskRow.swift+++ b/Scramble/Scramble/Components/TaskRow.swift@@ -1,33 +1,21 @@-import SwiftData import SwiftUI #if canImport(UIKit) import UIKit #endif -/// Single row in the per-phase task list. Composes a checkbox, the task name-/// (with the optional inline `WhyDisclosureView`), and an assignee avatar.-/// Long-press on the body region toggles the disclosure; swipe-trailing and-/// `contextMenu` mirror the Edit / Delete affordances per Req 7.1.-///-/// State ownership: `isDisclosureOpen` is bound by the parent; the resolved-/// `WhyDisclosure.Reason` is cached locally and recomputed only when the-/// disclosure opens or relevant inputs (`task.trip?.attributesData`,-/// `task.currentlyMatchesRules`, `task.name`) change.+/// Single row in the per-phase task list. Composes a checkbox, the task name,+/// and an assignee avatar. Swipe-trailing and `contextMenu` mirror the Edit /+/// Delete affordances per Req 7.1. struct TaskRow: View { let task: TripTask let phaseColour: Color- let isDisclosureOpen: Bool let onToggleComplete: () -> Void- let onLongPress: () -> Void let onEdit: () -> Void let onDelete: () -> 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? var body: some View { let variant = theme.variant(for: colorScheme)@@ -35,27 +23,15 @@ struct TaskRow: View { HStack(alignment: .top, spacing: 12) { checkbox - VStack(alignment: .leading, spacing: 6) {- Text(task.name)- .font(.body)- .strikethrough(task.isCompleted)- .foregroundStyle(variant.textPrimary)- .frame(maxWidth: .infinity, 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: .tasks(phaseColour: phaseColour))- #if DEBUG- .accessibilityIdentifier("tripDetail.whyDisclosure.\(task.name)")- #endif- }- }+ Text(task.name)+ .font(.body)+ .strikethrough(task.isCompleted)+ .foregroundStyle(variant.textPrimary)+ // minHeight: 44 centres a single-line name with the checkbox's 44pt box+ // (both top-align in the HStack); taller names grow past it and wrap.+ // Mirrors PackingItemRow's name column (T-1619); unconditional here since+ // the WhyDisclosure that once gated it is gone.+ .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) if let snapshot = Self.assigneeSnapshot(for: task) { PersonAvatar(name: snapshot.name, colorKey: snapshot.colourID, size: .compact)@@ -93,62 +69,12 @@ struct TaskRow: View { .accessibilityElement(children: .combine) .accessibilityLabel(Self.accessibilityLabel(for: task)) .accessibilityActions {- // Phase 6 Req 9.5 — "Why is this here?" custom action is- // exposed only when the underlying item has a rule- // justification (manual one-offs and items whose master row- // was deleted under certain conditions return nil from- // WhyResolver and therefore omit the action).- if Self.hasWhyJustification(- task: task,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- ) {- Button("Why is this here?") { onLongPress() }- } Button("Edit") { onEdit() } Button("Delete") { onDelete() } } #if DEBUG .accessibilityIdentifier("tripDetail.taskRow.\(task.name)") #endif- .onChange(of: isDisclosureOpen) { _, open in- if open {- resolvedReason = WhyResolver.reason(- for: task,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- )- } else {- resolvedReason = nil- }- }- .onChange(of: task.trip?.attributesData) { _, _ in- if isDisclosureOpen {- resolvedReason = WhyResolver.reason(- for: task,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- )- }- }- .onChange(of: task.currentlyMatchesRules) { _, _ in- if isDisclosureOpen {- resolvedReason = WhyResolver.reason(- for: task,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- )- }- }- .onChange(of: task.name) { _, _ in- if isDisclosureOpen {- resolvedReason = WhyResolver.reason(- for: task,- context: modelContext,- hideOnUnresolvedMaster: isParticipantViewingSharedTrip- )- }- } } // MARK: - Subviews@@ -206,7 +132,7 @@ struct TaskRow: View { return min(completedFactor, matchFactor) } - // MARK: - Accessibility (Phase 6 Req 9.2, 9.5)+ // MARK: - Accessibility (Phase 6 Req 9.2) /// Combined accessibility label: task name, completion state, optional /// assignee, phase. Read once per body re-evaluation; safe to call@@ -220,20 +146,4 @@ struct TaskRow: View { parts.append("phase \(task.phase.displayName)") return parts.joined(separator: ", ") }-- /// Phase 6 Req 9.5 — whether the "Why is this here?" custom action- /// should be exposed. Mirrors the long-press disclosure's gate:- /// `WhyResolver.reason(...)` returning non-nil means there is rule- /// justification to surface.- static func hasWhyJustification(- task: TripTask,- context: ModelContext,- hideOnUnresolvedMaster: Bool- ) -> Bool {- WhyResolver.reason(- for: task,- context: context,- hideOnUnresolvedMaster: hideOnUnresolvedMaster- ) != nil- } }
diff --git a/Scramble/Scramble/Explainability/ConditionsFormatter.swift b/Scramble/Scramble/Explainability/ConditionsFormatter.swiftdeleted file mode 100644index 4fc445c..0000000--- a/Scramble/Scramble/Explainability/ConditionsFormatter.swift+++ /dev/null@@ -1,58 +0,0 @@-import Foundation--/// Renders an `ItemConditions` tree as a human-readable explanation of which-/// trip attributes currently match.-///-/// Walks the tree, collects every `.match(attribute:, anyOf:)` leaf, and-/// for each `TripAttribute.allCases` intersects the union of the master's-/// allowed values for that attribute with the trip's currently selected-/// values. Each intersected value is rendered via `attributeValueDisplay`-/// (so output matches the chips and pickers elsewhere in the UI); the-/// per-attribute matched values are joined with `" or "`, and the-/// non-empty per-attribute strings are joined with `" + "` in-/// `TripAttribute.allCases` order.-///-/// `.always` and any condition whose `.match` leaves do not intersect the-/// trip's attributes return the empty string. The structure of `.all` /-/// `.any` does not influence the output — the formatter explains *which-/// attribute values currently overlap with the trip*, not the boolean shape-/// of the master rule.-enum ConditionsFormatter {-- nonisolated static func format(- _ conditions: ItemConditions,- against attributes: TripAttributes- ) -> String {- // Collect master-side allowed values per attribute (union across all- // `.match` leaves in the tree).- var masterValuesByAttribute: [TripAttribute: Set<String>] = [:]- collect(conditions, into: &masterValuesByAttribute)-- // For each attribute (in canonical order), keep only values currently- // selected on the trip, preserving the trip's selection order so the- // output is stable and predictable.- var groups: [String] = []- for attribute in TripAttribute.allCases {- guard let allowed = masterValuesByAttribute[attribute], !allowed.isEmpty else { continue }- let selected = attributes.selected(attribute)- let intersected = selected.filter { allowed.contains($0) }- guard !intersected.isEmpty else { continue }- groups.append(intersected.map(\.attributeValueDisplay).joined(separator: " or "))- }- return groups.joined(separator: " + ")- }-- private nonisolated static func collect(- _ conditions: ItemConditions,- into bucket: inout [TripAttribute: Set<String>]- ) {- switch conditions {- case .always:- return- case .match(let attribute, let anyOf):- bucket[attribute, default: []].formUnion(anyOf)- case .all(let children), .any(let children):- for child in children { collect(child, into: &bucket) }- }- }-}
diff --git a/Scramble/Scramble/Explainability/WhyDisclosure.swift b/Scramble/Scramble/Explainability/WhyDisclosure.swiftdeleted file mode 100644index 377a931..0000000--- a/Scramble/Scramble/Explainability/WhyDisclosure.swift+++ /dev/null@@ -1,115 +0,0 @@-import SwiftUI--/// Namespace for the explainability panel and its `Reason` enum. The view-/// (`WhyDisclosureView`) and the resolved reason share this namespace so-/// `WhyResolver` can produce a `WhyDisclosure.Reason` without depending on-/// the view layer.-enum WhyDisclosure {- /// Why a given `TripTask` is on this trip. Computed on demand by- /// `WhyResolver` and rendered by `WhyDisclosureView`.- enum Reason: Equatable, Sendable {- /// User added this task manually for this trip.- case manual- /// The rule that created this task no longer exists (master deleted or- /// `masterItemID` is nil).- case ruleMasterDeleted- /// The rule's master exists and at least one of its conditions currently- /// matches the trip's attributes. `conditionsText` is the formatted- /// explanation produced by `ConditionsFormatter`.- case ruleMatched(conditionsText: String)- /// The rule's master exists but no condition currently matches the- /// trip's attributes.- case ruleNoLongerMatches- }-- /// Visual treatment for `WhyDisclosureView`. Tasks render with a phase-tinted- /// background and 1pt border; packing rows render with a softer person-tinted- /// background and no border (Phase 4 design §"Integration with WhyDisclosure").- nonisolated enum Style: Sendable {- case tasks(phaseColour: Color)- // Test-only since phase-4 Decision 10 (packing WhyDisclosure removed): no- // production caller, retained for WhyDisclosureStyleTests. Do not delete.- case packing(personColour: Color)-- var resolvedAppearance: ResolvedAppearance {- switch self {- case .tasks(let phaseColour):- return ResolvedAppearance(tint: phaseColour, backgroundOpacity: 0.08, borderOpacity: 0.20)- case .packing(let personColour):- return ResolvedAppearance(tint: personColour, backgroundOpacity: 0.06, borderOpacity: nil)- }- }- }-- /// Snapshot of the resolved appearance values driven by `Style`. Exposed- /// `nonisolated` so unit tests can assert the mapping table without- /// instantiating SwiftUI.- nonisolated struct ResolvedAppearance: Sendable, Equatable {- let tint: Color- let backgroundOpacity: Double- /// `nil` when no border should be drawn (packing variant).- let borderOpacity: Double?- }-}--/// Inline explainability panel rendered below a `TaskRow` (or a packing item-/// row) when its disclosure is open. Consumes a `WhyDisclosure.Reason`-/// (resolved by `WhyResolver`) and renders it per UI doc §"Visual treatment":-/// - Tasks context: phase colour at 8% bg, 20% border, phase-coloured WHY?-/// - Packing context: person colour at 6% bg, no border, person-coloured WHY?-struct WhyDisclosureView: View {- let reason: WhyDisclosure.Reason- let style: WhyDisclosure.Style-- @Environment(\.theme) private var theme- @Environment(\.colorScheme) private var colorScheme-- var body: some View {- let variant = theme.variant(for: colorScheme)- let appearance = style.resolvedAppearance-- VStack(alignment: .leading, spacing: 4) {- Text("WHY?")- .font(.system(size: 9, weight: .heavy))- .foregroundStyle(appearance.tint)-- Text(bodyText)- .font(.footnote)- .foregroundStyle(variant.textPrimary)- .fixedSize(horizontal: false, vertical: true)- }- .padding(.horizontal, 10)- .padding(.vertical, 8)- .frame(maxWidth: .infinity, alignment: .leading)- .background(- RoundedRectangle(cornerRadius: 8, style: .continuous)- .fill(appearance.tint.opacity(appearance.backgroundOpacity))- )- .overlay(borderOverlay(appearance: appearance))- }-- @ViewBuilder- private func borderOverlay(appearance: WhyDisclosure.ResolvedAppearance) -> some View {- if let borderOpacity = appearance.borderOpacity {- RoundedRectangle(cornerRadius: 8, style: .continuous)- .strokeBorder(appearance.tint.opacity(borderOpacity), lineWidth: 1)- }- }-- private var bodyText: String {- switch reason {- case .manual:- return "You added this manually for this trip."- case .ruleMasterDeleted:- return "Originally added by a rule that has since been removed."- case .ruleMatched(let conditionsText):- if conditionsText.isEmpty {- return "Matches your trip."- }- return "Matches your trip: \(conditionsText)"- case .ruleNoLongerMatches:- return- "No conditions currently match your trip's attributes — this task may have matched previously."- }- }-}
diff --git a/Scramble/Scramble/Explainability/WhyResolver.swift b/Scramble/Scramble/Explainability/WhyResolver.swiftdeleted file mode 100644index 5ab07e0..0000000--- a/Scramble/Scramble/Explainability/WhyResolver.swift+++ /dev/null@@ -1,120 +0,0 @@-import Foundation-import SwiftData-import os--/// Resolves the `WhyDisclosure.Reason` for a single `TripTask` against the-/// current store state.-///-/// Conventions:-/// - Manual tasks always return `.manual` regardless of `masterItemID`.-/// - Rule-driven tasks with `masterItemID == nil` or whose master can't be-/// fetched return `.ruleMasterDeleted`.-/// - Rule-driven tasks whose master's conditions evaluate true against the-/// trip's current `attributes` return `.ruleMatched(conditionsText:)`.-/// - Rule-driven tasks whose master's conditions evaluate false return-/// `.ruleNoLongerMatches`.-///-/// `MainActor` because it touches `ModelContext`.-@MainActor-enum WhyResolver {-- /// Resolves the `WhyDisclosure.Reason` for a `TripTask`. When- /// `hideOnUnresolvedMaster == true` (participant-side shared trip,- /// Req 3.3), a rule-driven item whose master cannot be found in the- /// current globals zone returns `nil` so the affordance is hidden from- /// the layout. Owner-viewed trips pass `false` and continue to return- /// `.ruleMasterDeleted` in the same situation.- static func reason(- for task: TripTask,- context: ModelContext,- hideOnUnresolvedMaster: Bool = false- ) -> WhyDisclosure.Reason? {- if task.source == .manual {- return .manual- }-- guard let masterID = task.masterItemID else {- return hideOnUnresolvedMaster ? nil : .ruleMasterDeleted- }-- let master: MasterTaskItem? = fetchMaster(id: masterID, context: context)- guard let master else {- return hideOnUnresolvedMaster ? nil : .ruleMasterDeleted- }-- let attributes = task.trip?.attributes ?? TripAttributes()- let conditions = master.conditions-- if conditions.evaluate(against: attributes) {- let text = ConditionsFormatter.format(conditions, against: attributes)- return .ruleMatched(conditionsText: text)- }- return .ruleNoLongerMatches- }-- // Test-only since phase-4 Decision 10 (packing WhyDisclosure removed): no- // production caller, retained for WhyResolverPackingTests /- // WhyResolverParticipantHideTests. Do not delete.- static func reason(- for item: TripPackingItem,- context: ModelContext,- hideOnUnresolvedMaster: Bool = false- ) -> WhyDisclosure.Reason? {- if item.source == .manual {- return .manual- }-- guard let masterID = item.masterItemID else {- return hideOnUnresolvedMaster ? nil : .ruleMasterDeleted- }-- let master: MasterPackingItem? = fetchMaster(id: masterID, context: context)- guard let master else {- return hideOnUnresolvedMaster ? nil : .ruleMasterDeleted- }-- let attributes = item.trip?.attributes ?? TripAttributes()- let conditions = master.conditions-- if conditions.evaluate(against: attributes) {- let text = ConditionsFormatter.format(conditions, against: attributes)- return .ruleMatched(conditionsText: text)- }- return .ruleNoLongerMatches- }-- // MARK: - Private-- private static func fetchMaster(id: UUID, context: ModelContext) -> MasterTaskItem? {- let descriptor = FetchDescriptor<MasterTaskItem>(- predicate: #Predicate { $0.id == id }- )- do {- return try context.fetch(descriptor).first- } catch {- // A fetch failure here (corrupt store, mid-migration schema mismatch)- // is indistinguishable from a missing master in the return value. The- // caller will surface this as `.ruleMasterDeleted`, which is the best- // user-facing fallback, but the actual error is worth logging so the- // misleading explanation can be investigated.- modelLogger.error(- "WhyResolver.fetchMaster failed for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)"- )- return nil- }- }-- private static func fetchMaster(id: UUID, context: ModelContext) -> MasterPackingItem? {- let descriptor = FetchDescriptor<MasterPackingItem>(- predicate: #Predicate { $0.id == id }- )- do {- return try context.fetch(descriptor).first- } catch {- modelLogger.error(- "WhyResolver.fetchMaster<MasterPackingItem> failed for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)"- )- return nil- }- }-}
diff --git a/Scramble/Scramble/Features/Trips/AccordionTimeline.swift b/Scramble/Scramble/Features/Trips/AccordionTimeline.swiftindex a45ac16..022a469 100644--- a/Scramble/Scramble/Features/Trips/AccordionTimeline.swift+++ b/Scramble/Scramble/Features/Trips/AccordionTimeline.swift@@ -12,13 +12,11 @@ import SwiftUI /// /// Single-site mutation of `expandedPhase` per design: `PhaseRow.onToggle` /// calls back here, and this view emits the medium-impact haptic-/// (Req 2.7), clears any open disclosure (Req 8.3), and performs the-/// `proxy.scrollTo(...)`.+/// (Req 2.7) and performs the `proxy.scrollTo(...)`. struct AccordionTimeline: View { let trip: Trip let today: Date @Binding var expandedPhase: Phase?- @Binding var openDisclosureTaskID: UUID? let onAddTaskInPhase: (Phase) -> Void let onEditTask: (TripTask) -> Void let onOpenPackingSheet: (Person, PackingMode) -> Void@@ -89,7 +87,6 @@ struct AccordionTimeline: View { trip: trip, phase: phase, phaseColour: colour,- openDisclosureTaskID: $openDisclosureTaskID, onAdd: { onAddTaskInPhase(phase) }, onEdit: onEditTask )@@ -127,7 +124,6 @@ struct AccordionTimeline: View { // animates inside this same block). withAnimation(.scrambleStandard) { expandedPhase = (expandedPhase == phase) ? nil : phase- openDisclosureTaskID = nil if expandedPhase == phase { proxy.scrollTo(phase, anchor: .top) }
diff --git a/Scramble/Scramble/Features/Trips/TripDetailView.swift b/Scramble/Scramble/Features/Trips/TripDetailView.swiftindex 430a75e..fe9d49f 100644--- a/Scramble/Scramble/Features/Trips/TripDetailView.swift+++ b/Scramble/Scramble/Features/Trips/TripDetailView.swift@@ -25,7 +25,6 @@ import os @State private var showLeaveConfirmation = false @State private var toastMessage: String? @State private var expandedPhase: Phase?- @State private var openDisclosureTaskID: UUID? @State private var pendingForm: TaskFormPresentation? @State private var packingSheetState: PackingSheetState? @State private var lastOpenedPackingPerson: Person?@@ -80,7 +79,6 @@ import os trip: trip, today: today, expandedPhase: $expandedPhase,- openDisclosureTaskID: $openDisclosureTaskID, onAddTaskInPhase: { phase in pendingForm = .add(phase: phase, trip: trip) },
diff --git a/Scramble/Scramble/Persistence/ParticipantViewingEnvironmentKey.swift b/Scramble/Scramble/Persistence/ParticipantViewingEnvironmentKey.swiftindex 124e9b1..7fe4314 100644--- a/Scramble/Scramble/Persistence/ParticipantViewingEnvironmentKey.swift+++ b/Scramble/Scramble/Persistence/ParticipantViewingEnvironmentKey.swift@@ -1,15 +1,13 @@ import SwiftUI /// Phase 5 — flags whether the current Trip Detail surface is being-/// rendered for a participant on a shared trip. Drives the WhyDisclosure-/// hide behaviour-/// (Req [3.3](../../specs/phase-5-cloudkit-sharing/requirements.md#3.3))-/// and the "Rules last evaluated" subline+/// rendered for a participant on a shared trip. Drives the participant+/// read-only rule-edit gating and the "Rules last evaluated" subline /// (Req [8.8](../../specs/phase-5-cloudkit-sharing/requirements.md#8.8)). ///-/// Set by `TripDetailView`; consumed by `TaskRow`, `PackingItemRow`, and-/// the WhyResolver. `false` for owner-viewed trips and for non-shared-/// trips (default).+/// Set by `TripDetailView`; consumed by `PackingSheet` and `PackingItemForm`+/// (category rule-edit gate). `false` for owner-viewed trips and for+/// non-shared trips (default). private struct IsParticipantViewingSharedTripKey: EnvironmentKey { static let defaultValue: Bool = false }
diff --git a/Scramble/Scramble/Persistence/UITestSeed.swift b/Scramble/Scramble/Persistence/UITestSeed.swiftindex 2b45d33..fc4fe25 100644--- a/Scramble/Scramble/Persistence/UITestSeed.swift+++ b/Scramble/Scramble/Persistence/UITestSeed.swift@@ -50,14 +50,13 @@ /// Phase 4: trip currently on `.dayBefore` (today == start - 1, end == /// today+6) with two participants and a mix of packing item states. Used /// by pack-mode UI tests covering the summary block, sheet groups,- /// checkbox toggle, Skip/Restore, manual add, dimmed-row counting, and- /// `WhyDisclosure` for rule-driven items.+ /// checkbox toggle, Skip/Restore, manual add, and dimmed-row counting+ /// (including rule-driven items). case phase4PackModeTrip = "phase4-pack-mode-trip" /// Phase 4: trip currently on `.dayBeforeReturn` (today == end-1, start /// == today-3) with two participants and a mix of packed/repacked/ /// unpacked/excluded items. Used by repack-mode UI tests covering the- /// "Left behind" group, read-only checkboxes, and `WhyDisclosure` on- /// read-only rows.+ /// "Left behind" group and read-only checkboxes. case phase4RepackModeTrip = "phase4-repack-mode-trip" }
diff --git a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swiftindex 895b701..722d377 100644--- a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift+++ b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift@@ -91,6 +91,9 @@ struct PackingItemRowAccessibilityTests { // MARK: - Helpers struct Setup {+ // Retain the container; a `ModelContext` does not keep its+ // `ModelContainer` alive, so dropping it crashes the test host.+ let container: ModelContainer let context: ModelContext } @@ -100,7 +103,7 @@ struct PackingItemRowAccessibilityTests { schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none ) let container = try ModelContainer(for: schema, configurations: [config])- return Setup(context: container.mainContext)+ return Setup(container: container, context: container.mainContext) } static func makeItem(
diff --git a/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift b/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swiftindex 4854a54..0fdcb2a 100644--- a/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift+++ b/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift@@ -4,8 +4,7 @@ import Testing @testable import Scramble -/// Phase 6 Req 9.2 + 9.5 — TaskRow combined accessibility label and the-/// "Why is this here?" custom action gate.+/// Phase 6 Req 9.2 — TaskRow combined accessibility label. @Suite("TaskRow accessibility", .serialized) @MainActor struct TaskRowAccessibilityTests {@@ -64,76 +63,13 @@ struct TaskRowAccessibilityTests { #expect(label.contains("assigned to Alice")) } - // MARK: - Why action gate (Req 9.5)-- @Test("Manual one-off task exposes the Why action (returns .manual reason)")- func manualTaskHasWhy() throws {- let setup = try Self.makeSetup()- let trip = Trip(name: "T", startDate: .now, endDate: .now)- setup.context.insert(trip)- let task = TripTask(- trip: trip,- name: "Manual",- phase: .departureDay,- isCompleted: false,- source: .manual- )- setup.context.insert(task)- try setup.context.save()-- let hasWhy = TaskRow.hasWhyJustification(- task: task, context: setup.context, hideOnUnresolvedMaster: false- )- #expect(hasWhy)- }-- @Test("Rule task with no master and hideOnUnresolvedMaster=true omits the Why action")- func participantUnresolvedMasterHidesWhy() throws {- let setup = try Self.makeSetup()- let trip = Trip(name: "T", startDate: .now, endDate: .now)- setup.context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: UUID(), // master not present in this context- name: "Rule",- phase: .departureDay,- isCompleted: false,- source: .rule- )- setup.context.insert(task)- try setup.context.save()-- let hasWhy = TaskRow.hasWhyJustification(- task: task, context: setup.context, hideOnUnresolvedMaster: true- )- #expect(!hasWhy)- }-- @Test("Owner view of rule task with deleted master still exposes Why (.ruleMasterDeleted)")- func ownerUnresolvedMasterShowsWhy() throws {- let setup = try Self.makeSetup()- let trip = Trip(name: "T", startDate: .now, endDate: .now)- setup.context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: UUID(),- name: "Rule",- phase: .departureDay,- isCompleted: false,- source: .rule- )- setup.context.insert(task)- try setup.context.save()-- let hasWhy = TaskRow.hasWhyJustification(- task: task, context: setup.context, hideOnUnresolvedMaster: false- )- #expect(hasWhy)- }- // MARK: - Helpers struct Setup {+ // Retain the container for the test's lifetime; a `ModelContext` does not+ // keep its `ModelContainer` alive, so returning only the context lets the+ // container deallocate out from under it and crashes the test host.+ let container: ModelContainer let context: ModelContext } @@ -143,6 +79,6 @@ struct TaskRowAccessibilityTests { schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none ) let container = try ModelContainer(for: schema, configurations: [config])- return Setup(context: container.mainContext)+ return Setup(container: container, context: container.mainContext) } }
diff --git a/Scramble/ScrambleTests/Explainability/ConditionsFormatterTests.swift b/Scramble/ScrambleTests/Explainability/ConditionsFormatterTests.swiftdeleted file mode 100644index f3ba000..0000000--- a/Scramble/ScrambleTests/Explainability/ConditionsFormatterTests.swift+++ /dev/null@@ -1,149 +0,0 @@-import Foundation-import Testing--@testable import Scramble--@Suite("ConditionsFormatter")-struct ConditionsFormatterTests {-- // MARK: - Helpers-- private static func attrs(_ pairs: [(TripAttribute, [String])]) -> TripAttributes {- var a = TripAttributes()- for (attr, vals) in pairs {- for v in vals { a.toggle(attr, value: v) }- }- return a- }-- // MARK: - AND across attribute types ('+')-- @Test("AND across attribute types joined with ' + '")- func andAcrossAttributeTypes() {- let cond: ItemConditions = .all([- .match(attribute: .weather, anyOf: ["rain"]),- .match(attribute: .duration, anyOf: ["week"]),- ])- let trip = Self.attrs([(.weather, ["rain"]), (.duration, ["week"])])- // TripAttribute.allCases order is: duration, transport, scope, weather, purpose- #expect(ConditionsFormatter.format(cond, against: trip) == "Week + Rain")- }-- // MARK: - OR within attribute type ('or')-- @Test("OR within attribute type joined with ' or '")- func orWithinAttributeType() {- let cond: ItemConditions = .match(attribute: .weather, anyOf: ["rain", "snow"])- let trip = Self.attrs([(.weather, ["rain", "snow"])])- #expect(ConditionsFormatter.format(cond, against: trip) == "Rain or Snow")- }-- @Test("OR within attribute type returns only intersected values")- func orWithinReturnsIntersection() {- let cond: ItemConditions = .match(attribute: .weather, anyOf: ["rain", "snow", "sun"])- // Trip only has rain selected — sun and snow are not in the trip's attrs.- let trip = Self.attrs([(.weather, ["rain"])])- #expect(ConditionsFormatter.format(cond, against: trip) == "Rain")- }-- // MARK: - Iteration order matches TripAttribute.allCases-- @Test("Iteration order follows TripAttribute.allCases regardless of insertion order")- func iterationOrderDeterministic() {- // Build the same logical condition with attribute types in reversed order.- // Order in source: purpose, weather, scope, transport, duration (reverse of allCases)- let cond: ItemConditions = .all([- .match(attribute: .purpose, anyOf: ["leisure"]),- .match(attribute: .weather, anyOf: ["rain"]),- .match(attribute: .scope, anyOf: ["domestic"]),- .match(attribute: .transport, anyOf: ["car"]),- .match(attribute: .duration, anyOf: ["week"]),- ])- let trip = Self.attrs([- (.purpose, ["leisure"]),- (.weather, ["rain"]),- (.scope, ["domestic"]),- (.transport, ["car"]),- (.duration, ["week"]),- ])- // Expected order matches TripAttribute.allCases:- // duration, transport, scope, weather, purpose- #expect(- ConditionsFormatter.format(cond, against: trip) == "Week + Car + Domestic + Rain + Leisure")- }-- // MARK: - Empty intersection-- @Test("Empty intersection (master conditions match nothing on trip) returns empty string")- func emptyIntersection() {- let cond: ItemConditions = .match(attribute: .weather, anyOf: ["snow"])- let trip = Self.attrs([(.weather, ["rain"])])- #expect(ConditionsFormatter.format(cond, against: trip) == "")- }-- @Test("Empty intersection when trip has no selected attributes returns empty string")- func emptyIntersectionNoTripAttrs() {- let cond: ItemConditions = .match(attribute: .weather, anyOf: ["rain"])- let trip = TripAttributes()- #expect(ConditionsFormatter.format(cond, against: trip) == "")- }-- // MARK: - .always-- @Test(".always returns empty string (no matched values)")- func alwaysReturnsEmpty() {- let trip = Self.attrs([(.weather, ["rain"]), (.duration, ["week"])])- #expect(ConditionsFormatter.format(.always, against: trip) == "")- }-- // MARK: - Nested / .any / .all combinations-- @Test(".all of two matches yields the same output as a top-level AND across types")- func allOfTwoMatches() {- let cond: ItemConditions = .all([- .match(attribute: .weather, anyOf: ["rain"]),- .match(attribute: .transport, anyOf: ["car"]),- ])- let trip = Self.attrs([(.weather, ["rain"]), (.transport, ["car"])])- // TripAttribute.allCases puts transport before weather.- #expect(ConditionsFormatter.format(cond, against: trip) == "Car + Rain")- }-- @Test(".any across types collapses to per-attribute OR groups joined by ' + '")- func anyAcrossTypes() {- let cond: ItemConditions = .any([- .match(attribute: .weather, anyOf: ["rain"]),- .match(attribute: .duration, anyOf: ["week"]),- ])- let trip = Self.attrs([(.weather, ["rain"]), (.duration, ["week"])])- // Both branches contribute distinct attribute types — duration first, then weather.- #expect(ConditionsFormatter.format(cond, against: trip) == "Week + Rain")- }-- @Test("Multiple .match branches on the same attribute type collapse to a single OR group")- func sameAttributeRepeated() {- let cond: ItemConditions = .any([- .match(attribute: .weather, anyOf: ["rain"]),- .match(attribute: .weather, anyOf: ["snow"]),- ])- let trip = Self.attrs([(.weather, ["rain", "snow"])])- // Both values match within weather — they are joined by ' or ' under a single group.- let out = ConditionsFormatter.format(cond, against: trip)- #expect(- out == "Rain or Snow" || out == "Snow or Rain",- "Got unexpected value: \(out)")- }-- // MARK: - Partial matches across multiple attributes-- @Test("Partial trip attribute selection only outputs the attributes that actually overlap")- func partialMatch() {- let cond: ItemConditions = .all([- .match(attribute: .weather, anyOf: ["rain"]),- .match(attribute: .duration, anyOf: ["week"]),- ])- // Trip selects only weather; duration not selected.- let trip = Self.attrs([(.weather, ["rain"])])- #expect(ConditionsFormatter.format(cond, against: trip) == "Rain")- }-}
diff --git a/Scramble/ScrambleTests/Explainability/WhyDisclosureStyleTests.swift b/Scramble/ScrambleTests/Explainability/WhyDisclosureStyleTests.swiftdeleted file mode 100644index 15550c8..0000000--- a/Scramble/ScrambleTests/Explainability/WhyDisclosureStyleTests.swift+++ /dev/null@@ -1,50 +0,0 @@-import SwiftUI-import Testing--@testable import Scramble--@Suite("WhyDisclosure.Style appearance mapping")-struct WhyDisclosureStyleTests {-- // MARK: - .tasks(phaseColour:)-- @Test("tasks variant uses phase colour as tint")- func tasksTintIsPhaseColour() {- let phase = Color.red- let appearance = WhyDisclosure.Style.tasks(phaseColour: phase).resolvedAppearance- #expect(appearance.tint == phase)- }-- @Test("tasks variant uses 8% background opacity")- func tasksBackgroundOpacity() {- let appearance = WhyDisclosure.Style.tasks(phaseColour: .blue).resolvedAppearance- #expect(appearance.backgroundOpacity == 0.08)- }-- @Test("tasks variant uses 20% border opacity")- func tasksBorderOpacity() {- let appearance = WhyDisclosure.Style.tasks(phaseColour: .blue).resolvedAppearance- #expect(appearance.borderOpacity == 0.20)- }-- // MARK: - .packing(personColour:)-- @Test("packing variant uses person colour as tint")- func packingTintIsPersonColour() {- let person = Color.green- let appearance = WhyDisclosure.Style.packing(personColour: person).resolvedAppearance- #expect(appearance.tint == person)- }-- @Test("packing variant uses 6% background opacity")- func packingBackgroundOpacity() {- let appearance = WhyDisclosure.Style.packing(personColour: .green).resolvedAppearance- #expect(appearance.backgroundOpacity == 0.06)- }-- @Test("packing variant has no border")- func packingBorderIsNil() {- let appearance = WhyDisclosure.Style.packing(personColour: .green).resolvedAppearance- #expect(appearance.borderOpacity == nil)- }-}
diff --git a/Scramble/ScrambleTests/Explainability/WhyResolverPackingTests.swift b/Scramble/ScrambleTests/Explainability/WhyResolverPackingTests.swiftdeleted file mode 100644index 4b741ba..0000000--- a/Scramble/ScrambleTests/Explainability/WhyResolverPackingTests.swift+++ /dev/null@@ -1,250 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import Scramble--@Suite("WhyResolver (TripPackingItem)", .serialized)-@MainActor-struct WhyResolverPackingTests {-- // MARK: - Container helper-- private static func makeContainer() throws -> ModelContainer {- let schema = Schema(versionedSchema: SchemaV3.self)- let config = ModelConfiguration(- schema: schema,- isStoredInMemoryOnly: true,- cloudKitDatabase: .none- )- return try ModelContainer(for: schema, configurations: [config])- }-- private static func rainyAttributes() -> TripAttributes {- var a = TripAttributes()- a.toggle(.weather, value: "rain")- return a- }-- private static func sunnyAttributes() -> TripAttributes {- var a = TripAttributes()- a.toggle(.weather, value: "sun")- return a- }-- // MARK: - .manual-- @Test("manual packing item → .manual reason")- func manualReason() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let person = Person(name: "Alice", colorKey: "blue")- context.insert(person)- let item = TripPackingItem(- trip: trip,- person: person,- masterItemID: nil,- name: "Sunscreen",- state: .unpacked,- source: .manual- )- context.insert(item)- try context.save()-- #expect(WhyResolver.reason(for: item, context: context) == .manual)- _ = container- }-- // MARK: - .ruleMasterDeleted-- @Test("rule packing item with nil masterItemID → .ruleMasterDeleted")- func ruleMasterDeletedNilID() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let person = Person(name: "Alice", colorKey: "blue")- context.insert(person)- let item = TripPackingItem(- trip: trip,- person: person,- masterItemID: nil,- name: "Orphan",- state: .unpacked,- source: .rule- )- context.insert(item)- try context.save()-- #expect(WhyResolver.reason(for: item, context: context) == .ruleMasterDeleted)- _ = container- }-- @Test("rule packing item whose masterItemID resolves to nothing → .ruleMasterDeleted")- func ruleMasterDeletedNotFound() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let person = Person(name: "Alice", colorKey: "blue")- context.insert(person)- let danglingID = UUID()- let item = TripPackingItem(- trip: trip,- person: person,- masterItemID: danglingID,- name: "Orphan",- state: .unpacked,- source: .rule- )- context.insert(item)- try context.save()-- #expect(WhyResolver.reason(for: item, context: context) == .ruleMasterDeleted)- _ = container- }-- @Test("rule packing item whose master was deleted after save → .ruleMasterDeleted")- func ruleMasterDeletedAfterCreation() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let person = Person(name: "Alice", colorKey: "blue")- context.insert(person)- let master = MasterPackingItem(- name: "Rain jacket",- person: person,- conditions: .match(attribute: .weather, anyOf: ["rain"])- )- context.insert(master)- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let item = TripPackingItem(- trip: trip,- person: person,- masterItemID: master.id,- name: "Rain jacket",- state: .unpacked,- source: .rule- )- context.insert(item)- try context.save()-- context.delete(master)- try context.save()-- #expect(WhyResolver.reason(for: item, context: context) == .ruleMasterDeleted)- _ = container- }-- // MARK: - .ruleMatched-- @Test(- "rule packing item with matching master + matching trip attrs → .ruleMatched with conditionsText"- )- func ruleMatched() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let person = Person(name: "Alice", colorKey: "blue")- context.insert(person)- let master = MasterPackingItem(- name: "Rain jacket",- person: person,- conditions: .match(attribute: .weather, anyOf: ["rain"])- )- context.insert(master)- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let item = TripPackingItem(- trip: trip,- person: person,- masterItemID: master.id,- name: "Rain jacket",- state: .unpacked,- source: .rule- )- context.insert(item)- try context.save()-- let reason = WhyResolver.reason(for: item, context: context)- #expect(reason == .ruleMatched(conditionsText: "Rain"))- _ = container- }-- // MARK: - .ruleNoLongerMatches-- @Test(- "rule packing item with master present but conditions no longer match trip → .ruleNoLongerMatches"- )- func ruleNoLongerMatches() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let person = Person(name: "Alice", colorKey: "blue")- context.insert(person)- let master = MasterPackingItem(- name: "Rain jacket",- person: person,- conditions: .match(attribute: .weather, anyOf: ["rain"])- )- context.insert(master)- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.sunnyAttributes())- context.insert(trip)- let item = TripPackingItem(- trip: trip,- person: person,- masterItemID: master.id,- name: "Rain jacket",- state: .unpacked,- source: .rule- )- context.insert(item)- try context.save()-- #expect(WhyResolver.reason(for: item, context: context) == .ruleNoLongerMatches)- _ = container- }-- // MARK: - Regression: resolver re-reads current trip attributes on each call-- @Test("Resolver reflects new trip attributes after mutation (no stale snapshot)")- func reflectsMutatedAttributes() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let person = Person(name: "Alice", colorKey: "blue")- context.insert(person)- let master = MasterPackingItem(- name: "Rain jacket",- person: person,- conditions: .match(attribute: .weather, anyOf: ["rain"])- )- context.insert(master)- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let item = TripPackingItem(- trip: trip,- person: person,- masterItemID: master.id,- name: "Rain jacket",- state: .unpacked,- source: .rule- )- context.insert(item)- try context.save()-- #expect(WhyResolver.reason(for: item, context: context) == .ruleMatched(conditionsText: "Rain"))-- trip.attributes = Self.sunnyAttributes()- try context.save()-- #expect(WhyResolver.reason(for: item, context: context) == .ruleNoLongerMatches)- _ = container- }-}
diff --git a/Scramble/ScrambleTests/Explainability/WhyResolverParticipantHideTests.swift b/Scramble/ScrambleTests/Explainability/WhyResolverParticipantHideTests.swiftdeleted file mode 100644index a07b56c..0000000--- a/Scramble/ScrambleTests/Explainability/WhyResolverParticipantHideTests.swift+++ /dev/null@@ -1,137 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import Scramble--/// Phase 5 — verifies the participant-side hide behaviour the-/// WhyDisclosure affordance relies on-/// (Req [3.3](../../../specs/phase-5-cloudkit-sharing/requirements.md#3.3)).-///-/// When the current viewer is a participant on a shared trip,-/// rule-driven items whose master cannot be resolved against the-/// participant's globals zone must return `nil` (affordance hidden) —-/// not `.ruleMasterDeleted`, which is the owner-side fallback.-@Suite("WhyResolver — participant-side hide on unresolved master")-@MainActor-struct WhyResolverParticipantHideTests {-- private func makeContext() throws -> ModelContext {- let schema = Schema(versionedSchema: SchemaV3.self)- let config = ModelConfiguration(- schema: schema,- isStoredInMemoryOnly: true,- cloudKitDatabase: .none- )- let container = try ModelContainer(for: schema, configurations: [config])- return ModelContext(container)- }-- // MARK: - TripTask-- @Test("Participant: rule-driven task with missing master returns nil")- func participantTaskWithUnresolvedMasterIsHidden() throws {- let context = try makeContext()- let trip = Trip(name: "Trip", startDate: .now, endDate: .now)- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: UUID(), // master not in participant globals- name: "Sunscreen reminder",- phase: .dayBefore,- source: .rule- )- context.insert(task)-- let reason = WhyResolver.reason(- for: task,- context: context,- hideOnUnresolvedMaster: true- )- #expect(reason == nil, "Affordance must be hidden when master can't be resolved")- }-- @Test("Owner: rule-driven task with missing master returns .ruleMasterDeleted")- func ownerTaskWithUnresolvedMasterFallsBack() throws {- let context = try makeContext()- let trip = Trip(name: "Trip", startDate: .now, endDate: .now)- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: UUID(),- name: "Cabin charger",- phase: .dayBefore,- source: .rule- )- context.insert(task)-- let reason = WhyResolver.reason(for: task, context: context)- #expect(reason == .ruleMasterDeleted)- }-- @Test("Participant: manual tasks still resolve to .manual (no hide)")- func participantManualTaskStillRenders() throws {- let context = try makeContext()- let trip = Trip(name: "Trip", startDate: .now, endDate: .now)- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: nil,- name: "Manual task",- phase: .dayBefore,- source: .manual- )- context.insert(task)-- let reason = WhyResolver.reason(- for: task,- context: context,- hideOnUnresolvedMaster: true- )- #expect(reason == .manual)- }-- // MARK: - TripPackingItem-- @Test("Participant: rule-driven packing item with missing master returns nil")- func participantPackingItemWithUnresolvedMasterIsHidden() throws {- let context = try makeContext()- let trip = Trip(name: "Trip", startDate: .now, endDate: .now)- context.insert(trip)- let item = TripPackingItem(- trip: trip,- person: nil,- masterItemID: UUID(),- name: "Sunscreen",- state: .unpacked,- source: .rule- )- context.insert(item)-- let reason = WhyResolver.reason(- for: item,- context: context,- hideOnUnresolvedMaster: true- )- #expect(reason == nil)- }-- @Test("Owner: rule-driven packing item with missing master falls back to .ruleMasterDeleted")- func ownerPackingItemWithUnresolvedMasterFallsBack() throws {- let context = try makeContext()- let trip = Trip(name: "Trip", startDate: .now, endDate: .now)- context.insert(trip)- let item = TripPackingItem(- trip: trip,- person: nil,- masterItemID: UUID(),- name: "Toothbrush",- state: .unpacked,- source: .rule- )- context.insert(item)-- let reason = WhyResolver.reason(for: item, context: context)- #expect(reason == .ruleMasterDeleted)- }-}
diff --git a/Scramble/ScrambleTests/Explainability/WhyResolverTests.swift b/Scramble/ScrambleTests/Explainability/WhyResolverTests.swiftdeleted file mode 100644index 9954c9b..0000000--- a/Scramble/ScrambleTests/Explainability/WhyResolverTests.swift+++ /dev/null@@ -1,221 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import Scramble--@Suite("WhyResolver", .serialized)-@MainActor-struct WhyResolverTests {-- // MARK: - Container helper-- private static func makeContainer() throws -> ModelContainer {- let schema = Schema(versionedSchema: SchemaV3.self)- let config = ModelConfiguration(- schema: schema,- isStoredInMemoryOnly: true,- cloudKitDatabase: .none- )- return try ModelContainer(for: schema, configurations: [config])- }-- private static func rainyAttributes() -> TripAttributes {- var a = TripAttributes()- a.toggle(.weather, value: "rain")- return a- }-- private static func sunnyAttributes() -> TripAttributes {- var a = TripAttributes()- a.toggle(.weather, value: "sun")- return a- }-- // MARK: - .manual-- @Test("manual task → .manual reason")- func manualReason() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: nil,- name: "One-off",- phase: .dayBefore,- source: .manual- )- context.insert(task)- try context.save()-- #expect(WhyResolver.reason(for: task, context: context) == .manual)- }-- // MARK: - .ruleMasterDeleted-- @Test("rule task with nil masterItemID → .ruleMasterDeleted")- func ruleMasterDeletedNilID() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: nil,- name: "Orphan",- phase: .dayBefore,- source: .rule- )- context.insert(task)- try context.save()-- #expect(WhyResolver.reason(for: task, context: context) == .ruleMasterDeleted)- }-- @Test("rule task whose masterItemID resolves to nothing → .ruleMasterDeleted")- func ruleMasterDeletedNotFound() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let danglingID = UUID()- let task = TripTask(- trip: trip,- masterItemID: danglingID,- name: "Orphan",- phase: .dayBefore,- source: .rule- )- context.insert(task)- try context.save()-- #expect(WhyResolver.reason(for: task, context: context) == .ruleMasterDeleted)- }-- @Test("rule task whose master was deleted after save → .ruleMasterDeleted")- func ruleMasterDeletedAfterCreation() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let master = MasterTaskItem(- name: "Pack umbrella",- phase: .dayBefore,- conditions: .match(attribute: .weather, anyOf: ["rain"])- )- context.insert(master)- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: master.id,- name: "Pack umbrella",- phase: .dayBefore,- source: .rule- )- context.insert(task)- try context.save()-- context.delete(master)- try context.save()-- #expect(WhyResolver.reason(for: task, context: context) == .ruleMasterDeleted)- }-- // MARK: - .ruleMatched-- @Test("rule task with matching master + matching trip attrs → .ruleMatched with conditionsText")- func ruleMatched() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let master = MasterTaskItem(- name: "Pack umbrella",- phase: .dayBefore,- conditions: .match(attribute: .weather, anyOf: ["rain"])- )- context.insert(master)- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: master.id,- name: "Pack umbrella",- phase: .dayBefore,- source: .rule- )- context.insert(task)- try context.save()-- let reason = WhyResolver.reason(for: task, context: context)- #expect(reason == .ruleMatched(conditionsText: "Rain"))- }-- // MARK: - .ruleNoLongerMatches-- @Test("rule task with master present but conditions no longer match trip → .ruleNoLongerMatches")- func ruleNoLongerMatches() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let master = MasterTaskItem(- name: "Pack umbrella",- phase: .dayBefore,- conditions: .match(attribute: .weather, anyOf: ["rain"])- )- context.insert(master)- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.sunnyAttributes())- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: master.id,- name: "Pack umbrella",- phase: .dayBefore,- source: .rule- )- context.insert(task)- try context.save()-- #expect(WhyResolver.reason(for: task, context: context) == .ruleNoLongerMatches)- }-- // MARK: - Regression: resolver re-reads current trip attributes on each call-- @Test("Resolver reflects new trip attributes after mutation (no stale snapshot)")- func reflectsMutatedAttributes() throws {- let container = try Self.makeContainer()- let context = container.mainContext-- let master = MasterTaskItem(- name: "Pack umbrella",- phase: .dayBefore,- conditions: .match(attribute: .weather, anyOf: ["rain"])- )- context.insert(master)- let trip = Trip(name: "T", startDate: .now, endDate: .now, attributes: Self.rainyAttributes())- context.insert(trip)- let task = TripTask(- trip: trip,- masterItemID: master.id,- name: "Pack umbrella",- phase: .dayBefore,- source: .rule- )- context.insert(task)- try context.save()-- // First call: matches.- #expect(WhyResolver.reason(for: task, context: context) == .ruleMatched(conditionsText: "Rain"))-- // Mutate trip attributes — switch to sunny.- trip.attributes = Self.sunnyAttributes()- try context.save()-- // Second call: same task, same context — should reflect new attrs.- #expect(WhyResolver.reason(for: task, context: context) == .ruleNoLongerMatches)- }-}
diff --git a/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift b/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swiftindex b505c2a..2742c3c 100644--- a/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift+++ b/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift@@ -16,7 +16,8 @@ struct TripPackingItemContentBridgeTests { @Test("subItems get/set round-trips through subItemsData") func subItemsRoundTrip() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Toys") context.insert(item) @@ -29,7 +30,8 @@ struct TripPackingItemContentBridgeTests { @Test("setting subItems = [] clears subItemsData to nil") func emptyListClearsData() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Books") context.insert(item) @@ -43,7 +45,8 @@ struct TripPackingItemContentBridgeTests { @Test("non-nil empty Data() reads back as []") func nonNilEmptyDataReadsAsEmpty() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Snacks") context.insert(item) @@ -55,7 +58,8 @@ struct TripPackingItemContentBridgeTests { @Test("garbage Data decodes to []") func garbageDataDecodesToEmpty() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Gear") context.insert(item) @@ -65,7 +69,8 @@ struct TripPackingItemContentBridgeTests { @Test("note set then cleared") func noteSetThenCleared() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Camera") context.insert(item) @@ -80,7 +85,8 @@ struct TripPackingItemContentBridgeTests { @Test("note via init") func noteViaInit() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Charger", note: "USB-C only") context.insert(item) try context.save()@@ -89,7 +95,8 @@ struct TripPackingItemContentBridgeTests { @Test("skip -> restore leaves note and subItems unchanged") func skipRestorePreservesContent() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Toys", note: "soft ones only") context.insert(item) item.subItems = ["bear", "blocks"]@@ -106,11 +113,15 @@ struct TripPackingItemContentBridgeTests { #expect(item.subItems == ["bear", "blocks"]) } - private static func makeContext() throws -> ModelContext {+ /// Returns the container (not just its `mainContext`): a `ModelContext`+ /// does not keep its `ModelContainer` alive, so handing back only the+ /// context lets the container deallocate and crashes the test host. Each+ /// caller binds the container to a local and derives the context from it.+ private static func makeContainer() throws -> ModelContainer { let schema = Schema(versionedSchema: SchemaV3.self) let config = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none )- return try ModelContainer(for: schema, configurations: [config]).mainContext+ return try ModelContainer(for: schema, configurations: [config]) } }
diff --git a/Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swift b/Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swiftindex 8866a0a..41d37c9 100644--- a/Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swift+++ b/Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swift@@ -17,7 +17,8 @@ struct TripPackingItemBridgeTests { @Test("Fast path: resolves via trip.participantSnapshots") func resolvesViaParticipantSnapshots() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let trip = Trip(name: "T", startDate: .now, endDate: .now) context.insert(trip) let snapshot = TripPersonSnapshot(personID: UUID(), name: "Alice", trip: trip)@@ -33,7 +34,8 @@ struct TripPackingItemBridgeTests { @Test("Fallback path: resolves via modelContext fetch when the trip side isn't wired") func resolvesViaContextFetchWhenTripNil() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let snapshot = TripPersonSnapshot(personID: UUID(), name: "Bob") context.insert(snapshot) // No trip relationship and no participantSnapshots, so the in-memory@@ -49,7 +51,8 @@ struct TripPackingItemBridgeTests { @Test("Nil personSnapshotID resolves to nil") func nilIDResolvesNil() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Towel") context.insert(item) try context.save()@@ -60,7 +63,8 @@ struct TripPackingItemBridgeTests { @Test("Dangling personSnapshotID (no matching snapshot) resolves to nil") func danglingIDResolvesNil() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let item = TripPackingItem(name: "Boots") item.personSnapshotID = UUID() // points at nothing context.insert(item)@@ -71,7 +75,8 @@ struct TripPackingItemBridgeTests { @Test("Setter stores the snapshot's id and clears on nil") func setterStoresID() throws {- let context = try Self.makeContext()+ let container = try Self.makeContainer()+ let context = container.mainContext let snapshot = TripPersonSnapshot(personID: UUID(), name: "Cara") context.insert(snapshot) let item = TripPackingItem(name: "Map")@@ -83,11 +88,15 @@ struct TripPackingItemBridgeTests { #expect(item.personSnapshotID == nil) } - private static func makeContext() throws -> ModelContext {+ /// Returns the container (not just its `mainContext`): a `ModelContext`+ /// does not keep its `ModelContainer` alive, so handing back only the+ /// context lets the container deallocate and crashes the test host. Each+ /// caller binds the container to a local and derives the context from it.+ private static func makeContainer() throws -> ModelContainer { let schema = Schema(versionedSchema: SchemaV3.self) let config = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none )- return try ModelContainer(for: schema, configurations: [config]).mainContext+ return try ModelContainer(for: schema, configurations: [config]) } }
diff --git a/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift b/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swiftindex 4c295eb..cd3706a 100644--- a/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift+++ b/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift@@ -177,6 +177,9 @@ struct NotificationsServiceTests { // MARK: - Helpers struct Setup {+ // Retain the container; a `ModelContext` does not keep its+ // `ModelContainer` alive, so dropping it crashes the test host.+ let container: ModelContainer let stub: StubNotificationCenter let service: NotificationsService let context: ModelContext@@ -222,7 +225,7 @@ struct NotificationsServiceTests { coalesceWindow: .milliseconds(100) ) return Setup(- stub: stub, service: service, context: context, calendar: cal+ container: container, stub: stub, service: service, context: context, calendar: cal ) } }
diff --git a/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift b/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swiftindex d8e3a54..6c55916 100644--- a/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift+++ b/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift@@ -318,6 +318,9 @@ struct RulesEngineRunnerTests { /// Bundle returned by `seedMatchedPackingItem` for the independence /// assertions (a struct rather than a 4-tuple per SwiftLint). private struct MatchedSeed {+ // Retain the container; a `ModelContext` does not keep its+ // `ModelContainer` alive, so dropping it crashes the test host.+ let container: ModelContainer let context: ModelContext let trip: Trip let person: Person@@ -355,7 +358,8 @@ struct RulesEngineRunnerTests { let runner = RulesEngineRunner(context: context) _ = try runner.runForTrip(trip) let item = try #require(try context.fetch(FetchDescriptor<TripPackingItem>()).first)- return MatchedSeed(context: context, trip: trip, person: person, item: item)+ return MatchedSeed(+ container: container, context: context, trip: trip, person: person, item: item) } @Test("Req 7.1/7.2: recompute leaves an existing item's note + subItems unchanged")
diff --git a/Scramble/ScrambleUITests/TimelineAndTaskUITests.swift b/Scramble/ScrambleUITests/TimelineAndTaskUITests.swiftindex 852002f..c0131d0 100644--- a/Scramble/ScrambleUITests/TimelineAndTaskUITests.swift+++ b/Scramble/ScrambleUITests/TimelineAndTaskUITests.swift@@ -125,118 +125,6 @@ final class TimelineAndTaskUITests: XCTestCase { ) } - @MainActor- func testLongPressOpensWhyDisclosure() {- let app = launchedApp(fixture: "phase3-trip-with-tasks")- openTripDetail(app, tripName: "Active Trip")-- let row = app.descendants(matching: .any)- .matching(identifier: "tripDetail.taskRow.Check the weather")- .firstMatch- XCTAssertTrue(row.waitForExistence(timeout: 5))- row.press(forDuration: 0.6)-- let disclosure = app.descendants(matching: .any)- .matching(identifier: "tripDetail.whyDisclosure.Check the weather")- .firstMatch- XCTAssertTrue(- disclosure.waitForExistence(timeout: 3),- "Long-press should expand the inline WhyDisclosure for the task"- )- }-- @MainActor- func testOnlyOneDisclosureOpenAtATime() {- let app = launchedApp(fixture: "phase3-trip-with-tasks")- openTripDetail(app, tripName: "Active Trip")-- // Expand .dayBefore so both rule and manual tasks are visible at once.- let dayBeforeHeader = app.descendants(matching: .any)- .matching(identifier: "tripDetail.phaseHeader.dayBefore")- .firstMatch- XCTAssertTrue(dayBeforeHeader.waitForExistence(timeout: 5))- dayBeforeHeader.tap()-- // Long-press the rule task on .dayBefore.- let rowA = app.descendants(matching: .any)- .matching(identifier: "tripDetail.taskRow.Charge devices")- .firstMatch- XCTAssertTrue(rowA.waitForExistence(timeout: 3))- rowA.press(forDuration: 0.6)-- let disclosureA = app.descendants(matching: .any)- .matching(identifier: "tripDetail.whyDisclosure.Charge devices")- .firstMatch- XCTAssertTrue(disclosureA.waitForExistence(timeout: 3))-- // Tapping the .duringTrip header re-collapses .dayBefore and clears the- // disclosure (single source: openDisclosureTaskID = nil on phase change).- let duringHeader = app.descendants(matching: .any)- .matching(identifier: "tripDetail.phaseHeader.duringTrip")- .firstMatch- XCTAssertTrue(duringHeader.waitForExistence(timeout: 3))- duringHeader.tap()-- let rowB = app.descendants(matching: .any)- .matching(identifier: "tripDetail.taskRow.Check the weather")- .firstMatch- XCTAssertTrue(rowB.waitForExistence(timeout: 3))- rowB.press(forDuration: 0.6)-- let disclosureB = app.descendants(matching: .any)- .matching(identifier: "tripDetail.whyDisclosure.Check the weather")- .firstMatch- XCTAssertTrue(disclosureB.waitForExistence(timeout: 3))-- // disclosureA should be gone — long-press on B closed any previously- // open one, AND the phase-change had already cleared it.- XCTAssertFalse(- app.descendants(matching: .any)- .matching(identifier: "tripDetail.whyDisclosure.Charge devices")- .firstMatch.exists,- "Opening a second disclosure must collapse the first"- )- }-- @MainActor- func testTapElsewhereDismissesDisclosure() {- let app = launchedApp(fixture: "phase3-trip-with-tasks")- openTripDetail(app, tripName: "Active Trip")-- let row = app.descendants(matching: .any)- .matching(identifier: "tripDetail.taskRow.Check the weather")- .firstMatch- XCTAssertTrue(row.waitForExistence(timeout: 5))- row.press(forDuration: 0.6)-- let disclosure = app.descendants(matching: .any)- .matching(identifier: "tripDetail.whyDisclosure.Check the weather")- .firstMatch- XCTAssertTrue(disclosure.waitForExistence(timeout: 3))-- // Tap the section's inert background by tapping near the dashed Add- // affordance area. The TaskListSection installs an .onTapGesture that- // clears openDisclosureTaskID.- let addButton = app.descendants(matching: .any)- .matching(identifier: "tripDetail.addTaskButton.duringTrip")- .firstMatch- XCTAssertTrue(addButton.waitForExistence(timeout: 3))- // Tap just above the add button: the TaskListSection content area.- let section = app.descendants(matching: .any)- .matching(identifier: "tripDetail.taskListSection.duringTrip")- .firstMatch- XCTAssertTrue(section.exists)- section.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.95)).tap()-- let predicate = NSPredicate(format: "exists == false")- let expectation = XCTNSPredicateExpectation(predicate: predicate, object: disclosure)- XCTAssertEqual(- XCTWaiter().wait(for: [expectation], timeout: 3),- .completed,- "Tap elsewhere within the expanded phase should dismiss the disclosure"- )- }- @MainActor func testSwipeRevealsEditAndDelete() { let app = launchedApp(fixture: "phase3-trip-with-tasks")
diff --git a/docs/agent-notes/accessibility.md b/docs/agent-notes/accessibility.mdindex 0135627..7297cdd 100644--- a/docs/agent-notes/accessibility.md+++ b/docs/agent-notes/accessibility.md@@ -14,17 +14,15 @@ Conventions used by Phase 6 polish: expand/collapse state (`double tap to expand` / `double tap to collapse`). - `TaskRow` — combined label includes task name + completion state +- assigned person + phase. Default activation toggles completion.- Custom action `"Why is this here?"` exposes the disclosure content- without long-press; gated by `WhyResolver.reason(...)` (suppressed- when the item has no rule justification).+ assigned person + phase. Default activation toggles completion. The+ custom actions are Edit and Delete only; the "why is this here?" action+ was removed in T-1617 along with the rest of the explainability surface. - `PackingItemRow` — combined label includes item name + current `PackingState` + owning person name. Excluded items labelled `"not bringing"`; repack-mode Left Behind items labelled- `"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`.+ `"left behind"`. No row type exposes a "why is this here?" action — the+ explainability surface was removed from packing in phase-4 Decision 10+ and from tasks (with the whole subsystem) in T-1617. - Per-person packing progress bar in `PackingSummarySection` — `accessibilityValue` reads `"{name}'s packing, {packed} of {total} packed"`.@@ -32,12 +30,6 @@ 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 (`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 Targets:@@ -69,7 +61,6 @@ 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` | 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 6624259..ab59b96 100644--- a/docs/agent-notes/packing-sheet.md+++ b/docs/agent-notes/packing-sheet.md@@ -44,34 +44,17 @@ 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)+## Explainability surface removed entirely (T-1617) -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).+Packing rows first lost the "why is this here?" explainability panel in phase-4 Decision 10 — the long-press gesture, the inline disclosure view, the `openDisclosureItemID` sheet state + background dismiss-tap target, and the VoiceOver "Why is this here?" action were all removed from `PackingItemRow` / `PackingSheet` (the owner found it unused and the long-press made the row interaction noisy; a deliberate divergence from phase-4 Req 6.5 / 7.10 and phase-6 Req 9.5). -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.+T-1617 then removed the same surface from task rows and, with no remaining production caller, **deleted the entire explainability subsystem (`Scramble/Scramble/Explainability/`) and its test suites**. There is no longer any "why is this here?" affordance anywhere in the app, and no resolver/formatter code. See `specs/remove-task-why-disclosure/decision_log.md` Decision 1. -## Gotcha: `PackingItemRowAccessibilityTests` crashes when run in isolation+## Gotcha: container-creating test suites must retain the `ModelContainer` -`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").+`PackingItemRowAccessibilityTests` (and other `@MainActor .serialized` suites that build a fresh `ModelContainer(for: SchemaV3)` per test) used to **crash the xctest process** — every test reported `failed (0.000 seconds)` and the suite re-ran (xcodebuild relaunches after a crash). The cause was the non-retained-`ModelContainer` anti-pattern: helpers returned only `container.mainContext` (or a `Setup` struct holding just the context), so the container deallocated out from under the live context. The simulator used to mask this via ARC timing, but the current Xcode/sim runtime no longer does, so it surfaced as a whole-suite, host-level crash (no assertion recorded). See `rules/language-rules/swift.md` → "Retain the ModelContainer in tests — never use a temporary". -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?)`:--| Case | tint | background | border |-|---|---|---|---|-| `.tasks` | phase colour | 0.08 | 0.20 |-| `.packing` | person colour | 0.06 | nil (no border) |--`TaskRow.swift` is the only Phase 3 call site that migrated. No deprecated overload — in-tree migration was small enough that a deprecation surface wasn't worth carrying.--## WhyResolver overload — no protocol abstraction--`WhyResolver.reason(for:context:)` has two `@MainActor` overloads — one for `TripTask`, one for `TripPackingItem`. They share ~30 lines of identical four-branch logic (manual → manual; rule with missing master → ruleMasterDeleted; rule with matching/non-matching conditions). The duplication is deliberate (`specs/phase-4-packing-sheet/design.md` §"Integration with WhyResolver"): abstracting into a `Whyable` protocol would re-touch Phase 3's shipped task overload + tests for an ~30-line saving. Revisit if a third overload or a fifth branch appears.+T-1617 fixed the affected helpers — their `Setup`/seed structs now retain the container alongside the context, and the bridge-test helpers return the container instead of a bare context. **When adding a container-creating test helper, keep the container retained for the test's lifetime** or the host will crash on this runtime. ## Sheet-on-sheet manual-add
diff --git a/docs/agent-notes/phase-5-ui-surfaces.md b/docs/agent-notes/phase-5-ui-surfaces.mdindex f235265..1c19aba 100644--- a/docs/agent-notes/phase-5-ui-surfaces.md+++ b/docs/agent-notes/phase-5-ui-surfaces.md@@ -3,8 +3,7 @@ The Trip Detail share affordance, Participants section, Trip List migration banner + Syncing badge, and the participant-only "Rules last evaluated" subline. Tests live under `ScrambleUITests/Phase5*UITests`-and `ScrambleTests/Sharing/RulesLastEvaluatedTrackerTests`,-`ScrambleTests/Explainability/WhyResolverParticipantHideTests`.+and `ScrambleTests/Sharing/RulesLastEvaluatedTrackerTests`. ## Files @@ -57,13 +56,12 @@ and `ScrambleTests/Sharing/RulesLastEvaluatedTrackerTests`, - **Owner check is synchronous** — `sharingService.ownerIdentity(forTrip:)` reads `TripZoneState` only (Req 10.4). Safe to call per render.-- **Participant-side WhyDisclosure** — the- `\.isParticipantViewingSharedTrip` environment flag is set by- `TripDetailView` and consumed by `TaskRow` /- `PackingItemRow` /- `WhyResolver.reason(...hideOnUnresolvedMaster:)`. When true and the- master record isn't in globals, the resolver returns `nil` and the- affordance is omitted from the layout entirely (Req 3.3).+- **`isParticipantViewingSharedTrip` flag** — set by `TripDetailView`+ and consumed by `PackingSheet` / `PackingItemForm` to gate+ participant-side read-only category rule-edits, plus the+ "Rules last evaluated" subline. (It formerly also drove the+ participant-side "why is this here?" disclosure hide behaviour, removed+ with the explainability subsystem in T-1617.) - **MigrationGate in tests** — `ScrambleApp` skips `enqueueAll() + runStageB() + syncEngine.start()` whenever `EnvironmentProbe.isUITestHost`, `isTest`, or `isPreview` is true.
diff --git a/docs/agent-notes/rules-engine.md b/docs/agent-notes/rules-engine.mdindex 5919ba5..c33f78d 100644--- a/docs/agent-notes/rules-engine.md+++ b/docs/agent-notes/rules-engine.md@@ -79,11 +79,17 @@ The project compiles under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Value ty - CloudKit sync as a re-evaluation trigger — deferred to the CKShare phase (Decision 4). The scenePhase trigger bounds the cross-device staleness window meanwhile. - Background-thread execution — family-scale bounds keep all work on the main actor. -## Explainability (Phase 3)--`WhyDisclosure` introspection landed in Phase 3 as a separate two-piece chain that does **not** live in the engine:--- `Scramble/Scramble/Explainability/WhyResolver.swift` — `@MainActor static func reason(for task: TripTask, context: ModelContext) -> WhyDisclosure.Reason`. Fetches the `MasterTaskItem` by ID, evaluates `master.conditions.evaluate(against: trip.attributes)`, and maps to one of four `.manual / .ruleMasterDeleted / .ruleMatched(text) / .ruleNoLongerMatches` cases.-- `Scramble/Scramble/Explainability/ConditionsFormatter.swift` — `nonisolated static func format(_ conditions: ItemConditions, against: TripAttributes) -> String`. Walks the conditions tree, intersects per-attribute master-allowed values with trip-selected values, renders via `attributeValueDisplay`, joins with `" or "` within an attribute type and `" + "` across types in `TripAttribute.allCases` order.--Per Decision 8.9, the matched conditions are **computed on demand** by intersecting the master item's current conditions with the trip's current attributes — never snapshotted at task-creation time. The resolver runs on the main actor because `ModelContext.fetch` is `@MainActor`; the formatter is `nonisolated` because it operates on pure value types.+## Explainability removed (T-1617)++The Phase 3 "why is this here?" introspection chain — the resolver and the+conditions formatter that lived under `Scramble/Scramble/Explainability/` —+has been **deleted**. It was always a separate two-piece chain that did not+live in the engine, so its removal does not touch matching or diffing. The+owner removed the surface (the long-press disclosure on task rows; packing+had already lost it in Decision 10), and with no remaining production caller+the whole subsystem and its tests were deleted. See+`specs/remove-task-why-disclosure/decision_log.md` Decision 1, which also+overrides the design docs' "computed on demand" explainability principle.++The engine's matching, diffing, and `currentlyMatchesRules` dimming are+unchanged — there is simply no longer any on-demand "why" computation.
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 08fd9e3..bf54e4d 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -12,6 +12,7 @@ | [Copy Master Packing Items](#copy-master-packing-items) | 2026-06-20 | Done | Copy a master packing item to other people from the Master Lists tab, creating independent copies the rules engine materialises onto trips. | | [Packing Item Sub-items](#packing-item-sub-items) | 2026-06-21 | Done | Per-trip free-form note plus an appendable sub-item list on packing items, shown inline on the packing sheet and synced across the shared trip. | | [Packing Item Categories](#packing-item-categories) | 2026-06-27 | Done | Optional free-text category on packing items, grouping the Packing Sheet and Master Lists by category with autocomplete suggestions; category re-stamps onto trip items via the rules engine. |+| [Remove Task WhyDisclosure](#remove-task-whydisclosure) | 2026-06-29 | Done | Remove the task-row WhyDisclosure explainability surface and delete the now-orphaned `Explainability/` subsystem (`WhyResolver` / `WhyDisclosureView` / `ConditionsFormatter`); the task row was its last production consumer after packing was stripped in phase-4 Decision 10. | --- @@ -112,3 +113,11 @@ Optional free-text category on packing items, grouping the Packing Sheet and Mas - [prerequisites.md](packing-item-categories/prerequisites.md) - [requirements.md](packing-item-categories/requirements.md) - [tasks.md](packing-item-categories/tasks.md)++## Remove Task WhyDisclosure++Remove the task-row WhyDisclosure explainability surface and delete the now-orphaned `Explainability/` subsystem (`WhyResolver` / `WhyDisclosureView` / `ConditionsFormatter`); the task row was its last production consumer after packing was stripped in phase-4 Decision 10.++- [decision_log.md](remove-task-why-disclosure/decision_log.md)+- [smolspec.md](remove-task-why-disclosure/smolspec.md)+- [tasks.md](remove-task-why-disclosure/tasks.md)
diff --git a/specs/remove-task-why-disclosure/decision_log.md b/specs/remove-task-why-disclosure/decision_log.mdnew file mode 100644index 0000000..0d6180d--- /dev/null+++ b/specs/remove-task-why-disclosure/decision_log.md@@ -0,0 +1,39 @@+# Decision Log: Remove Task WhyDisclosure++## Decision 1: Delete the whole Explainability subsystem, not just the task UI++**Date**: 2026-06-29+**Status**: accepted — supersedes the task-side explainability carve-out preserved by Phase 4 Decision 10, and overrides the "Explainability is computed on demand" sections of `docs/scramble-design-doc.md` and the WhyDisclosure UI sections of `docs/scramble-ui-design-doc.md`++### Context++T-1617 removes the `WhyDisclosure` panel from task rows ("It doesn't add anything and makes for a lousy interface"). Phase 4 Decision 10 had already removed the same surface from packing, but deliberately *kept* the shared `WhyResolver` / `WhyDisclosureView` / `ConditionsFormatter` code and its packing test-only overload because the task surface still used it. The task row is the only remaining production consumer of that stack; removing it leaves `WhyResolver`, `WhyDisclosureView`, and `ConditionsFormatter` with zero production callers — alive only to satisfy their own test suites.++### Decision++Delete the entire `Scramble/Scramble/Explainability/` subsystem (`WhyDisclosure.swift`, `WhyResolver.swift`, `ConditionsFormatter.swift`) and its test suites, rather than leaving them as test-only code. The `isParticipantViewingSharedTrip` environment key is retained (it has independent packing and subline consumers); only its doc comment is updated.++### Rationale++The only reason Decision 10 retained the shared explainability code was to avoid churning a subsystem the task surface still depended on. That dependency is now gone, so the retention rationale evaporates. Keeping three source files plus five test suites alive purely to test code nothing ships would be dead weight a future reader could mistake for a live feature — exactly the "do not mistake for dead code" hazard Decision 10 had to footnote. A clean delete is the honest end state.++### Alternatives Considered++- **UI-only removal (mirror Decision 10)**: Remove only the task-row WhyDisclosure UI and state chain, keep `WhyResolver` / `WhyDisclosureView` / `ConditionsFormatter` and their tests as test-only code — Rejected: Decision 10 kept that code *because tasks used it*; with tasks gone it is dead code retained solely to keep its own tests green, which is churn without benefit.+- **Keep the VoiceOver action, drop only the long-press**: Rejected — the action surfaces the same panel and pays the same `WhyResolver` fetch; keeping it is inconsistent with removing the visual affordance (same reasoning as Decision 10).++### Consequences++**Positive:**+- No dead/test-only subsystem left behind; `Explainability/` is gone entirely.+- Task rows lose the long-press gesture, the per-row resolver fetches, and the disclosure state chain (`openDisclosureTaskID` through four views) — simpler rows and less plumbing.++**Negative:**+- Tasks lose in-app "why is this here?" explainability (sighted and VoiceOver). Accepted per owner request.+- The `docs/scramble-design-doc.md` "explainability is computed on demand" principle and the `docs/scramble-ui-design-doc.md` WhyDisclosure UI sections no longer have a surface; this ADR is the authoritative override (the design docs are not rewritten, matching the Decision 10 precedent).++### Impact++`Scramble/Scramble/Components/TaskRow.swift`, `TaskListSection.swift`, `Scramble/Scramble/Features/Trips/AccordionTimeline.swift`, `TripDetailView.swift`, `Scramble/Scramble/Persistence/ParticipantViewingEnvironmentKey.swift` (doc comment); deletes the `Explainability/` source folder and its test suites; trims the WhyDisclosure tests from `TaskRowAccessibilityTests.swift` and `TimelineAndTaskUITests.swift`; notes updated in `CLAUDE.md` and `docs/agent-notes/`.++---
diff --git a/specs/remove-task-why-disclosure/smolspec.md b/specs/remove-task-why-disclosure/smolspec.mdnew file mode 100644index 0000000..6610043--- /dev/null+++ b/specs/remove-task-why-disclosure/smolspec.md@@ -0,0 +1,49 @@+# Remove Task WhyDisclosure++## Overview++The `WhyDisclosure` ("why is this here?") explainability panel on task rows is being removed — the owner finds it adds nothing and degrades the task-row interface (T-1617). The packing surface was already stripped in Phase 4 Decision 10, so the task row is the last production consumer of the shared explainability stack (`WhyResolver`, `WhyDisclosureView`, `ConditionsFormatter`). Removing it leaves that stack with zero production callers, so the whole `Explainability/` subsystem and its tests are deleted rather than left as dead code.++## Requirements++- The system MUST remove the task-row WhyDisclosure panel, its long-press trigger (and the long-press haptic), and the "Why is this here?" VoiceOver custom action from `TaskRow`.+- The system MUST remove the disclosure-open state chain (`openDisclosureTaskID`) threaded through `TripDetailView` → `AccordionTimeline` → `TaskListSection` → `TaskRow`, including the tap-to-dismiss background layer and the phase-change reset.+- The system MUST delete the `Explainability/` subsystem (`WhyDisclosure.swift`, `WhyResolver.swift`, `ConditionsFormatter.swift`) once it has no remaining references.+- The system MUST delete the now-orphaned explainability test suites and remove the WhyDisclosure-specific tests from `TaskRowAccessibilityTests` and `TimelineAndTaskUITests`. After the change `TaskRowAccessibilityTests` MUST retain exactly its three accessibility-label tests (`labelBasic`, `labelCompleted`, `labelIncludesAssignee`) and no `hasWhyJustification` tests; `TimelineAndTaskUITests` MUST retain its non-disclosure tests and lose only `testLongPressOpensWhyDisclosure`, `testOnlyOneDisclosureOpenAtATime`, and `testTapElsewhereDismissesDisclosure`.+- The system MUST leave no stale reference to the removed explainability concept anywhere in the tree — including the `TaskRow.swift` file-header doc comment, the `ParticipantViewingEnvironmentKey.swift` doc comment, and the `WhyDisclosure` mentions in `UITestSeed.swift`'s seed-case comments (already stale for packing since Decision 10).+- The system MUST keep the `isParticipantViewingSharedTrip` environment key (it has non-explainability consumers) and update its doc comment to drop the removed WhyDisclosure / WhyResolver / `TaskRow` references.+- The system MUST leave the rules engine (matching, diffing, `currentlyMatchesRules`) and all other task-list behaviour unchanged.+- The system SHOULD record the removal as a decision-log entry that authoritatively overrides the explainability sections of the source-of-truth design docs (following the Phase 4 Decision 10 precedent), and update `CLAUDE.md` plus the affected `docs/agent-notes/` to match the new code.+- The full unit + UI suite (`make test`) MUST pass after the change.++## Implementation Approach++Pure deletion cascade; no new logic. Pattern reference: Phase 4 Decision 10 (`specs/phase-4-packing-sheet/decision_log.md`) and PR #12 (commit `92f4479`) removed the same surface from packing — follow its shape, but go further by deleting the now-orphaned shared subsystem.++Key files to modify:+- `Scramble/Scramble/Components/TaskRow.swift` — remove the `resolvedReason` state, the `modelContext` + `isParticipantViewingSharedTrip` environment reads (used only by the resolver), the `.onLongPressGesture` (and its haptic) plus the now-pointless `.contentShape(Rectangle())` on the task-name `Text` that only existed to back that gesture, the `WhyDisclosureView` block, the four `resolvedReason` `onChange` recomputes, the "Why is this here?" accessibility action (the `.accessibilityActions` block retains only its `Edit` / `Delete` actions), the `hasWhyJustification` static func, and the `onLongPress` closure parameter from the view's interface. Update the file-header doc comment to drop the WhyDisclosure / `isDisclosureOpen` description.+- `Scramble/Scramble/Components/TaskListSection.swift` — remove the `openDisclosureTaskID` binding, the `isDisclosureOpen` / `onLongPress` wiring on `TaskRow`, `toggleDisclosure`, and the dismiss-tap background layer.+- `Scramble/Scramble/Features/Trips/AccordionTimeline.swift` — remove the `openDisclosureTaskID` binding, its forward to `TaskListSection`, and the `openDisclosureTaskID = nil` reset on phase change.+- `Scramble/Scramble/Features/Trips/TripDetailView.swift` — remove the `openDisclosureTaskID` `@State` and the binding passed to `AccordionTimeline`.+- `Scramble/Scramble/Persistence/ParticipantViewingEnvironmentKey.swift` — update the doc comment only; the key stays (consumers are now `PackingSheet` / `PackingItemForm` rule-edit gating and the "Rules last evaluated" subline).++Files to delete:+- `Scramble/Scramble/Explainability/WhyDisclosure.swift`, `WhyResolver.swift`, `ConditionsFormatter.swift`.+- `Scramble/ScrambleTests/Explainability/WhyDisclosureStyleTests.swift`, `WhyResolverTests.swift`, `WhyResolverPackingTests.swift`, `WhyResolverParticipantHideTests.swift`, `ConditionsFormatterTests.swift`.++Tests to edit (not delete):+- `Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift` — remove the three `hasWhyJustification` tests; keep the three accessibility-label tests.+- `Scramble/ScrambleUITests/TimelineAndTaskUITests.swift` — remove `testLongPressOpensWhyDisclosure`, `testOnlyOneDisclosureOpenAtATime`, `testTapElsewhereDismissesDisclosure`; keep the rest.++Docs:+- Add the decision-log entry for this feature; update `CLAUDE.md` and the affected `docs/agent-notes/` (`accessibility.md`, `packing-sheet.md`, `rules-engine.md`, `phase-5-ui-surfaces.md`) where they describe the live WhyDisclosure / WhyResolver implementation. The stale `WhyDisclosure` mentions in `Scramble/Scramble/Persistence/UITestSeed.swift`'s seed-case comments MUST be corrected (covered by the no-stale-reference requirement above), since the type they name no longer exists.++Out of Scope: the rules engine matching/diffing logic; the `isParticipantViewingSharedTrip` key itself and the "Rules last evaluated" subline; any persistence/schema change; the packing surfaces (already removed in Decision 10); rewriting the source-of-truth design docs beyond the ADR override (the ADR is the authoritative record, matching the Decision 10 precedent).++## Risks and Assumptions++- Assumption: `WhyResolver`, `WhyDisclosureView`, and `ConditionsFormatter` have no production callers other than `TaskRow` after the task-row edit. Validation: `grep` for each symbol across `Scramble/Scramble` returns no non-test hits before deleting the files.+- Risk: a `#if DEBUG` accessibility identifier (`tripDetail.whyDisclosure.*`) or a `whyDisclosure` reference lingers in a UI test or seed and fails the build. Mitigation: `grep` for `whyDisclosure` / `WhyDisclosure` across the whole tree before finishing; `make test` exercises the UI tests.+- Risk: removing `@Environment(\.modelContext)` / `@Environment(\.isParticipantViewingSharedTrip)` from `TaskRow` while another line in the file still uses one causes a compile error. Mitigation: the compiler catches it; confirm those reads have no remaining use in `TaskRow` before deleting them.+- Assumption: the retained `TimelineAndTaskUITests` tests are independent of the three deleted disclosure tests (no shared mutable seed or launch ordering between them). Validation: the suite runs serially (per the Makefile) and each test launches its own seeded app; `make test` confirms the retained tests still pass after the deletions.+- Prerequisite: branched from current `main`, which already contains the packing-side removal (Decision 10, commit `92f4479`).
diff --git a/specs/remove-task-why-disclosure/tasks.md b/specs/remove-task-why-disclosure/tasks.mdnew file mode 100644index 0000000..74dc498--- /dev/null+++ b/specs/remove-task-why-disclosure/tasks.md@@ -0,0 +1,33 @@+---+references:+ - smolspec.md+ - decision_log.md+---+# Remove Task WhyDisclosure++- [x] 1. Task rows render with no WhyDisclosure surface or disclosure-open state <!-- id:605qzb4 -->+ - Remove from TaskRow: the WhyDisclosure panel, the long-press gesture + haptic, the now-orphaned .contentShape on the task-name Text, the four resolvedReason onChange recomputes, the resolvedReason state, the "Why is this here?" accessibility action (leaving only Edit/Delete in the block), the hasWhyJustification static func, and the modelContext + isParticipantViewingSharedTrip reads (resolver-only).+ - Remove the onLongPress/isDisclosureOpen interface and the openDisclosureTaskID state chain + dismiss-tap layer from TaskListSection, AccordionTimeline, and TripDetailView.+ - Delete the three hasWhyJustification tests in TaskRowAccessibilityTests; keep labelBasic/labelCompleted/labelIncludesAssignee.+ - Verify: app builds; make test-quick passes; the Explainability subsystem is untouched and still compiles.+ - References: smolspec.md++- [x] 2. Explainability subsystem is gone from the codebase with no remaining references <!-- id:605qzb5 -->+ - After confirming via grep that WhyResolver, WhyDisclosureView, and ConditionsFormatter have no remaining production references (only doc comments), delete Scramble/Scramble/Explainability/{WhyDisclosure,WhyResolver,ConditionsFormatter}.swift.+ - Delete the five dedicated suites: WhyDisclosureStyleTests, WhyResolverTests, WhyResolverPackingTests, WhyResolverParticipantHideTests, ConditionsFormatterTests.+ - Verify: app builds; make test-quick passes; grep for the three symbols returns only doc-comment hits (cleared in task 4).+ - Blocked-by: 605qzb4 (Task rows render with no WhyDisclosure surface or disclosure-open state)+ - References: smolspec.md++- [x] 3. Task-disclosure UI tests are removed and the full test suite passes <!-- id:605qzb6 -->+ - Remove testLongPressOpensWhyDisclosure, testOnlyOneDisclosureOpenAtATime, and testTapElsewhereDismissesDisclosure from ScrambleUITests/TimelineAndTaskUITests.swift; keep every other test in that file.+ - Verify: full make test (unit + UI) passes and the retained timeline/task UI tests are green.+ - Blocked-by: 605qzb5 (Explainability subsystem is gone from the codebase with no remaining references)+ - References: smolspec.md++- [x] 4. No stale WhyDisclosure/WhyResolver references remain in code comments or docs <!-- id:605qzb7 -->+ - Clear stale references: TaskRow.swift file-header doc comment, ParticipantViewingEnvironmentKey.swift doc comment, and the WhyDisclosure mentions in UITestSeed.swift seed-case comments.+ - Update CLAUDE.md and the affected docs/agent-notes (accessibility.md, packing-sheet.md, rules-engine.md, phase-5-ui-surfaces.md); confirm the decision_log.md entry reads correctly.+ - Verify: a tree-wide grep for WhyDisclosure/WhyResolver/ConditionsFormatter returns hits only inside specs/remove-task-why-disclosure/.+ - Blocked-by: 605qzb6 (Task-disclosure UI tests are removed and the full test suite passes)+ - References: smolspec.md, decision_log.md
This branch is based on 9855eab; origin/main is 84243bf. Rebase/merge and re-run tests before pushing. Expected conflicts: TaskRow.swift (take this branch), the container-retention helpers (either side), CHANGELOG.md.
The current simulator/test-host is degraded (DebuggerLLDB StoreError, 144s startup). Unit failures (LocalWriteHookPBT.mixedZonePartition, TripSyncEventBusTests.stopCancelsIteration) are in untouched sharing code; UI timeouts reproduce identically on the pre-change base commit. Confirm green elsewhere before merge.