scramble branch T-1587/packing-item-subitems commits 12 files 25 touched (8 prod, 9 test, 8 docs/spec) lines +2876 / -67

Pre-push review: packing-item-subitems (T-1587)

Per-trip free-form note + appendable sub-item list on packing items — model, sync/migration, and packing-sheet UI, plus the on-device trailing-glyph UI rework. Whole branch reviewed vs origin/main (never pushed).

At a glance

  • Major race in inline-editor visibility reporting — fixed by collapsing two per-binding callbacks into one combined onChange source of truth.
  • item.subItems JSON-decoded 3× per row render — fixed by threading the hoisted value into rowContent (now 1×).
  • UI was reworked post-design (trailing glyphs + inline note editing); now documented in Decision 14 (supersedes Decision 13) and a design.md banner.
  • New inline note path (PackingItemGroup.saveNote, note glyph) has no executable test yet — flagged, not blocking (harness down on Xcode 26.5).
  • Minor copy-paste (a11y-modifier structs, glyph buttons, field chrome) left as-is — established per-action convention; refactor risks the just-approved visuals.

Verdict

Ready to push

One major finding (an editor-switch race that could leave the sheet's background dismiss tap-catcher un-mounted) and one efficiency finding (3× JSON decode of subItems per row render) were found and fixed. Spec drift from the UI rework is now captured in Decision 14 and a design.md annotation. Remaining items are minor/established-convention (skipped) or carried as documented follow-ups. make build + make lint + make format are clean.

Caveat: the unit/UI test suite cannot be executed under the current Xcode 26.5 SwiftData multi-container SIGTRAP (see docs/agent-notes/testing.md); verification is build + lint only, and new tests are authored-but-unrun.

Review findings

11 raised · 6 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Packing items can now carry a free-form note (e.g. "check the batteries") and a list of sub-items (e.g. Toys → dice, cards, blocks). You add them while packing: each row has two small icons next to the Skip button — a note icon and a list icon — that pop open a little text field right on the row. The note and sub-items show under the item, sync to other devices on the shared trip, and don't change whether the item is packed.

Why it matters

Generic items like "books" or "toys" needed a way to jot down exactly which ones, without a heavyweight screen. The first design put an "+ add item" row under every item, which made the list look cluttered on a real phone, so it was reworked into the two compact icons.

Key concepts

  • Note = one piece of text; sub-items = a short list (max 50, 200 chars each).
  • Editing happens inline (on the row), saved automatically when you tap away.
  • Nothing about the item's packed/not-packed state changes when you edit notes or sub-items.

Architecture

Two new Optional properties on TripPackingItemnote: String? and subItemsData: Data? — ride on the existing SchemaV3 via lightweight inference (no SchemaV4). subItems: [String] is a CodableBridge computed property over the blob (empty list → nil data). Pure validation lives in a view/model-free PackingSubItems enum (caps 50/200/500, grapheme-cluster capping, positional duplicate-safe removal). Sync rides the existing TripPackingItemRecordTranslator with unconditional from assignment for clear-propagation; ZoneMigrationCoordinator.relocateTrip carries both fields.

Patterns

The UI is split: PackingSubItemsView is purely presentational (plain-value params, no @Model); PackingItemRow owns the editor reveal state (isAddingSubItem/isEditingNote) and passes bindings down. Mutations (sub-item add/remove, note save) all funnel through PackingItemGroup → the shared LocalWriteHook commit chokepoint. Sub-item rows use a UUID-keyed draft mirror so ForEach has stable identity despite duplicates.

Trade-offs

The note/sub-item caps (200) deliberately mirror the item-name cap but are kept as separate constants (documented intentional mirroring, not coupled). Editor visibility is reported from a single combined onChange rather than two — see the Expert tab for why.

The race that was fixed

Originally, PackingSubItemsView fired onAddFieldVisibilityChanged from two independent .onChange handlers (one per binding), and the sheet mapped that to activeAddFieldItemID = visible ? id : nil. Switching directly between editors sets one binding false and the other true in the same update; SwiftUI gives no ordering guarantee between sibling .onChange handlers, so if the false resolved last, the sheet ended up with activeAddFieldItemID == nil while an editor was open — dropping the background dismiss tap-catcher. The fix moves visibility reporting to a single onChange(of: isAddingSubItem || isEditingNote) in the parent: the combined boolean stays true across a switch, so it only fires on the genuine none↔some transitions.

Efficiency

item.subItems runs CodableBridge.decode (a JSONDecoder().decode) on every access. The body hoisted it once but rowContent/addSubItemButton/the a11y gate re-read item.subItems, costing 3 decodes per row render (recurring on scroll, toggle, and per-keystroke .onChange re-renders). Now the hoisted value is threaded through rowContent and canAddSubItem/hasNote are derived once.

Edge cases & observation

PackingItemRow.body must read item.note/item.subItems directly so SwiftData establishes observation (an inbound sync re-renders the row, re-seeding the draft mirror). The translator's from requires a full server snapshot (absent field = cleared) — documented. Migration safety hinges on the two fields being Optional on the shared TripPackingItem (no new schema version) — the same constraint behind the on-device "Duplicate version checksums" crash that was sidestepped by wiping the dev store.

Important changes — detailed

PackingItemRow: single combined editor-visibility report

Scramble/Scramble/Components/PackingItemRow.swift

Why it matters. Removes a SwiftUI ordering race that could leave the note editor open with no background dismiss path. This is the one behavioural bug found in review.

What to look at. PackingItemRow.swift — isAnyInlineEditorOpen + onChange

Takeaway. When two booleans both feed one downstream flag, derive the flag from a single combined value, not from two independent .onChange handlers — sibling onChange ordering is undefined.
Rationale. Collapsing to one source of truth (isAddingSubItem || isEditingNote) makes a same-tick switch a no-op for the sheet, which is the correct semantics.

PackingItemRow: decode subItems once per render

Scramble/Scramble/Components/PackingItemRow.swift

Why it matters. subItems decodes JSON on each access; it was read 3x per row render. Threading the hoisted value through rowContent makes it 1x.

What to look at. rowContent(variant:note:subItems:) + hasNote/canAddSubItem locals

Takeaway. A SwiftData computed-bridge property (JSON blob) is not free — hoist it once per body and pass it down rather than re-reading the @Model.
Rationale. The body already observed note/subItems for SwiftData; reusing those values both preserves observation and avoids redundant decodes.

Inline note editing via the LocalWriteHook chokepoint

Scramble/Scramble/Features/Trips/PackingSheet.swift

Why it matters. The note is now edited on the row (note glyph) rather than the edit form; the new saveNote keeps all per-trip content writes on one save path.

What to look at. PackingItemGroup.saveNote

Takeaway. Route new per-row mutations through the existing commit chokepoint (hook.commit) so the sync pipeline carries them with no new persistence surface.
Rationale. Consistency with addSubItem/removeSubItem; sanitizedNote (trim + 500-cap, nil on empty) is applied at the save boundary.

TripPackingItem note/subItems ride on SchemaV3

Scramble/Scramble/Models/TripPackingItem.swift

Why it matters. Two Optional fields added to a class shared across schema versions — must not be a new schema version (the duplicate-checksum trap).

What to look at. TripPackingItem.swift:note/subItemsData + subItems bridge

Takeaway. Property-only additions to a shared top-level @Model must be Optional/nil-default on the current version, never a distinct VersionedSchema.
Rationale. Matches the documented Trip.countryCode precedent; SwiftData adds the columns via lightweight inference.

Decision 14 captures the post-design UI rework

specs/packing-item-subitems/decision_log.md

Why it matters. The shipped UI contradicts Decision 13; without a logged decision this is silent drift a future reader would mistake for a bug.

What to look at. decision_log.md — Decision 14 (supersedes 13)

Takeaway. When an on-device review changes the design, supersede the old decision and annotate the design doc rather than editing history.
Rationale. Keeps the decision log as the authoritative trail; design.md now carries a reworked-post-design banner pointing to it.

Key decisions

Single combined editor-visibility source of truth.

Report inline-editor visibility from onChange(of: isAddingSubItem || isEditingNote) in the parent rather than two per-binding callbacks from the child, eliminating the sibling-onChange ordering race.

Trailing glyphs + inline note editing (Decision 14).

Replaced the persistent “+ add item” row with note/list glyphs and moved note editing inline, after on-device review showed the affordance row doubled every interactive row's height. Supersedes Decision 13.

Keep the 200 item-name / sub-item caps as separate constants.

Not coupled despite the same value — they are conceptually distinct limits with documented intentional mirroring; coupling would create a cross-module dependency that could break if they ever diverge.

(inferred — not stated by the author.)
Note/sub-item fields Optional on SchemaV3, no SchemaV4.

Property-only additions to the shared TripPackingItem ride on V3 via lightweight inference — a distinct schema version would collide checksums (the on-device launch crash).

Review findings

SeverityAreaFindingResolution
majorPackingItemRow / PackingSubItemsView / PackingSheet — editor switchInline-editor visibility was reported from two independent .onChange handlers, each setting activeAddFieldItemID; a direct switch between editors could leave it nil while an editor is open (no ordering guarantee), dropping the sheet's background dismiss tap-catcher.Report from a single onChange(of: isAddingSubItem || isEditingNote) in PackingItemRow; removed onAddFieldVisibilityChanged from PackingSubItemsView.
minorPackingItemRow — render hot pathitem.subItems (JSON decode) read 3x per row render — body hoisted it but rowContent / addSubItemButton / the a11y gate re-read the @Model.Thread the hoisted subItems into rowContent; derive hasNote / canAddSubItem once. Now 1 decode per render.
majordecision logThe UI rework (commit fa8cdbd) contradicts Decision 13 with no logged decision.Added Decision 14 (supersedes Decision 13).
majordesign.mddesign.md still described the superseded reveal-on-tap / form-based note approach unannotated.Added a reworked-post-design banner pointing to Decision 14.
minortests — PackingSubItemsPackingSubItems.cappedNote had no coverage.Added cappedNote cap + no-trim tests.
minordocs/agent-notesNo agent-note on the two inline editors or the direct item.note/subItems observation reads.Added a packing-sheet.md section.
minorPackingSubItems vs PackingItemFormcappedEntry/maxItemLength (200) mirrors cappedName/nameLimit (200).Skipped — conceptually distinct caps with documented intentional mirroring; coupling them would be worse.
minorPackingItemRow / PackingSubItemsView — a11y modifiersFive tiny *AccessibilityAction structs share one shape; could be a single generic.Skipped — established per-action convention in the file (three predate the branch).
minorPackingItemRow / PackingSubItemsView — copy-pastenoteEditor vs addField chrome, and the note vs list glyph buttons, are near-identical.Skipped — refactoring the just-approved visuals for marginal dedup is not worth the regression risk; the explicit forms read clearly.
majortests — inline note pathPackingItemGroup.saveNote and the inline note editor / glyph gating have no executable test.Flagged as a follow-up (documented in Decision 14). Not authored blind because the unit/UI suite cannot run under the Xcode 26.5 SwiftData crash.
minorPackingSubItemsView — keyboarddesign's 'scroll the row above the keyboard on reveal' (ScrollViewReader) is not wired for the inline editors.Carried as an open item in Decision 14 — revisit if the inline field proves cramped under the keyboard.

Per-file diffs

Click to expand.

Scramble/Scramble/Components/PackingItemRow.swift Modified +222 / -53
diff --git a/Scramble/Scramble/Components/PackingItemRow.swift b/Scramble/Scramble/Components/PackingItemRow.swiftindex 1ba2372..eb9b875 100644--- a/Scramble/Scramble/Components/PackingItemRow.swift+++ b/Scramble/Scramble/Components/PackingItemRow.swift@@ -88,42 +88,62 @@ struct PackingItemRow: View {   let onSkipOrRestore: () -> Void   let onLongPress: () -> Void   let onEdit: () -> Void+  /// Saves the inline-edited note (raw text). Wired to+  /// `PackingItemGroup.saveNote` — the note glyph edits inline rather than via+  /// the edit form.+  let onSaveNote: (String) -> Void+  /// Appends a sub-item (raw text). Wired to `PackingItemGroup.addSubItem`.+  let onAddSubItem: (String) -> Void+  /// Removes the sub-item at the given list index. Wired to+  /// `PackingItemGroup.removeSubItem`.+  let onRemoveSubItem: (Int) -> Void+  /// Reports the inline add field's visibility so the sheet can mount its+  /// dismiss tap-catcher.+  let onAddFieldVisibilityChanged: (Bool) -> Void    @Environment(\.modelContext) private var modelContext   @Environment(\.theme) private var theme   @Environment(\.colorScheme) private var colorScheme   @Environment(\.isParticipantViewingSharedTrip) private var isParticipantViewingSharedTrip   @State private var resolvedReason: WhyDisclosure.Reason?+  /// Drives the inline add field in `PackingSubItemsView`, toggled by the+  /// sub-item (list) glyph in the trailing controls (left of Skip).+  @State private var isAddingSubItem = false+  /// Drives the inline note editor in `PackingSubItemsView`, toggled by the+  /// note glyph. Mutually exclusive with `isAddingSubItem`.+  @State private var isEditingNote = false++  /// True while either inline editor (note or sub-item) is showing. Reported to+  /// the sheet as a single source of truth so a direct switch between the two+  /// editors (one binding false, the other true in the same update) never+  /// momentarily reads as "closed" — see `handleInlineEditorVisibility`.+  private var isAnyInlineEditorOpen: Bool { isAddingSubItem || isEditingNote }    var body: some View {     let variant = theme.variant(for: colorScheme)--    HStack(alignment: .top, spacing: 12) {-      checkbox(variant: variant)--      VStack(alignment: .leading, spacing: 6) {-        // Italic condition tags rendered when master available (deferred; placeholder for v1)-        Text(item.name)-          .font(.body)-          .foregroundStyle(group.isReadOnly ? variant.textSecondary : 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: .packing(personColour: personColour))-            #if DEBUG-              .accessibilityIdentifier("packingSheet.whyDisclosure.\(item.name)")-            #endif-        }-      }--      trailingAction(variant: variant)+    // Read note + subItems here (not only inside the child) so SwiftData+    // establishes observation on `note`/`subItemsData` — that is what makes an+    // inbound sync re-render the row (design § "Integration points").+    let note = item.note+    let subItems = item.subItems++    VStack(alignment: .leading, spacing: 6) {+      rowContent(variant: variant, note: note, subItems: subItems)++      PackingSubItemsView(+        note: note,+        subItems: subItems,+        isInteractive: !group.isReadOnly,+        accent: personColour,+        isAddFieldVisible: $isAddingSubItem,+        isEditingNote: $isEditingNote,+        onAdd: onAddSubItem,+        onRemove: onRemoveSubItem,+        onSaveNote: onSaveNote+      )+      // Indent the note + sub-items to line up under the item name (checkbox+      // width 44 + the row HStack spacing 12) rather than the row's leading edge.+      .padding(.leading, 56)     }     .padding(.vertical, 8)     .frame(minHeight: 44)@@ -147,28 +167,12 @@ struct PackingItemRow: View {         }       }     }-    .accessibilityElement(children: .combine)-    .accessibilityLabel(accessibilityLabel)-    .modifier(-      WhyAccessibilityAction(-        enabled: PackingItemRow.hasWhyJustification(-          item: item,-          context: modelContext,-          hideOnUnresolvedMaster: isParticipantViewingSharedTrip-        ),-        onWhy: onLongPress-      )-    )-    .modifier(EditAccessibilityAction(enabled: !group.isReadOnly, onEdit: onEdit))-    .modifier(-      SkipRestoreAccessibilityAction(-        label: inlineActionLabel,-        onAction: onSkipOrRestore-      )-    )     #if DEBUG       .accessibilityIdentifier("packingSheet.itemRow.\(item.name)")     #endif+    .onChange(of: isAnyInlineEditorOpen) { _, open in+      handleInlineEditorVisibility(open)+    }     .onChange(of: isDisclosureOpen) { _, open in       if open {         resolvedReason = WhyResolver.reason(@@ -202,6 +206,81 @@ struct PackingItemRow: View {    // MARK: - Subviews +  /// Checkbox + name + `WhyDisclosure` + trailing action. This is the single+  /// combined accessibility "row" element (name + state + owner + note in its+  /// label) carrying the row-level custom actions, including the new+  /// **Add sub-item** action (Req 8.2). The sub-item list is a sibling+  /// `.contain` container rendered by `PackingSubItemsView` so its entries stay+  /// individually addressable — the previous flat `.combine` over the whole+  /// row could not express that.+  @ViewBuilder+  private func rowContent(variant: ThemeVariant, note: String?, subItems: [String]) -> some View {+    // Derive once from the values the body already read — `item.subItems`+    // decodes its JSON blob on every access, so the glyph guard and the+    // accessibility-action gate reuse this rather than re-reading the model.+    let hasNote = !(note?.isEmpty ?? true)+    let canAddSubItem = !group.isReadOnly && subItems.count < PackingSubItems.maxCount+    HStack(alignment: .top, spacing: 12) {+      checkbox(variant: variant)+      nameColumn(variant: variant)+      noteButton(variant: variant, hasNote: hasNote)+      addSubItemButton(variant: variant, canAdd: canAddSubItem)+      trailingAction(variant: variant)+    }+    .accessibilityElement(children: .combine)+    .accessibilityLabel(combinedLabel(note: note))+    .modifier(+      WhyAccessibilityAction(+        enabled: PackingItemRow.hasWhyJustification(+          item: item,+          context: modelContext,+          hideOnUnresolvedMaster: isParticipantViewingSharedTrip+        ),+        onWhy: onLongPress+      )+    )+    .modifier(EditAccessibilityAction(enabled: !group.isReadOnly, onEdit: onEdit))+    .modifier(+      SkipRestoreAccessibilityAction(+        label: inlineActionLabel,+        onAction: onSkipOrRestore+      )+    )+    .modifier(+      AddSubItemAccessibilityAction(+        enabled: canAddSubItem,+        onAdd: { onAddSubItem("New sub-item") }+      )+    )+  }++  /// Item name + (when open) the `WhyDisclosure`. The name is given a 44pt+  /// min-height so its vertical centre lines up with the checkbox and trailing+  /// glyphs while the row HStack stays top-aligned (disclosure flows beneath).+  @ViewBuilder+  private func nameColumn(variant: ThemeVariant) -> some View {+    VStack(alignment: .leading, spacing: 6) {+      Text(item.name)+        .font(.body)+        .foregroundStyle(group.isReadOnly ? variant.textSecondary : variant.textPrimary)+        .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)+        .contentShape(Rectangle())+        .onLongPressGesture(minimumDuration: 0.4) {+          #if canImport(UIKit)+            UIImpactFeedbackGenerator(style: .light).impactOccurred()+          #endif+          onLongPress()+        }++      if isDisclosureOpen, let reason = resolvedReason {+        WhyDisclosureView(reason: reason, style: .packing(personColour: personColour))+          #if DEBUG+            .accessibilityIdentifier("packingSheet.whyDisclosure.\(item.name)")+          #endif+      }+    }+  }+   @ViewBuilder   private func checkbox(variant: ThemeVariant) -> some View {     if group.isReadOnly {@@ -238,6 +317,60 @@ struct PackingItemRow: View {     }   } +  /// Note glyph (left of the list glyph) that reveals the inline note editor —+  /// add when the item has no note, edit when it does. Tinted person-colour+  /// when a note exists, secondary when empty, as a lightweight has-note hint.+  /// Replaces editing the note through the full edit form. Hidden on read-only+  /// rows.+  @ViewBuilder+  private func noteButton(variant: ThemeVariant, hasNote: Bool) -> some View {+    if !group.isReadOnly {+      Button {+        isAddingSubItem = false+        isEditingNote = true+      } label: {+        Image(systemName: "note.text")+          .font(.system(size: 15, weight: .semibold))+          .foregroundStyle(hasNote ? personColour : variant.textSecondary)+          .frame(width: 44, height: 44)+          .contentShape(Rectangle())+      }+      .buttonStyle(.plain)+      .disabled(isEditingNote)+      .accessibilityLabel(hasNote ? "Edit note" : "Add note")+      #if DEBUG+        .accessibilityIdentifier("packingSubItems.noteButton")+      #endif+    }+  }++  /// Sub-item (list) glyph that reveals the inline add field, just left of the+  /// Skip button. Replaces the old persistent "add item" affordance row so a+  /// sub-item-less item stays a single line. Shown on interactive rows below+  /// the count cap; `.disabled` while already adding avoids a double-tap+  /// re-seeding the field.+  @ViewBuilder+  private func addSubItemButton(variant: ThemeVariant, canAdd: Bool) -> some View {+    if canAdd {+      Button {+        isEditingNote = false+        isAddingSubItem = true+      } label: {+        Image(systemName: "list.bullet")+          .font(.system(size: 15, weight: .semibold))+          .foregroundStyle(personColour)+          .frame(width: 44, height: 44)+          .contentShape(Rectangle())+      }+      .buttonStyle(.plain)+      .disabled(isAddingSubItem)+      .accessibilityLabel("Add sub-item")+      #if DEBUG+        .accessibilityIdentifier("packingSubItems.addButton")+      #endif+    }+  }+   @ViewBuilder   private func trailingAction(variant: ThemeVariant) -> some View {     if let label = inlineActionLabel {@@ -289,6 +422,17 @@ struct PackingItemRow: View {     #endif   } +  /// Forwards either inline editor's visibility (note editor or sub-item add+  /// field) to the sheet so it can mount the dismiss tap-catcher, and closes+  /// this row's `WhyDisclosure` when an editor reveals — one inline expansion+  /// at a time (design § "Focus / keyboard / dismissal").+  private func handleInlineEditorVisibility(_ visible: Bool) {+    if visible && isDisclosureOpen {+      onLongPress()  // toggles the open disclosure closed+    }+    onAddFieldVisibilityChanged(visible)+  }+   // MARK: - Visual state derivation    /// Whether the row's checkbox is rendered in the "checked" state. Items@@ -344,17 +488,18 @@ struct PackingItemRow: View {   }    /// Combined VoiceOver label per Phase 6 Req 9.3 — item name + current-  /// `PackingState` + owning person. Read-only groups (`.leftBehind`,-  /// `.notBringing`) substitute the special state suffix the spec-  /// mandates ("left behind", "not bringing") rather than the raw-  /// PackingState word.-  private var accessibilityLabel: String {-    PackingItemRow.composedAccessibilityLabel(item: item, group: group)+  /// `PackingState` + owning person, plus the note (Req 8.1) when present.+  /// Read-only groups (`.leftBehind`, `.notBringing`) substitute the special+  /// state suffix the spec mandates ("left behind", "not bringing") rather+  /// than the raw PackingState word.+  private func combinedLabel(note: String?) -> String {+    PackingItemRow.composedAccessibilityLabel(item: item, group: group, note: note)   }    static func composedAccessibilityLabel(     item: TripPackingItem,-    group: SheetGroup+    group: SheetGroup,+    note: String? = nil   ) -> String {     var parts: [String] = [item.name]     switch group {@@ -368,6 +513,13 @@ struct PackingItemRow: View {     if let ownerName = item.personSnapshot?.name, !ownerName.isEmpty {       parts.append("owned by \(ownerName)")     }+    // Req 8.1 — the note is read as part of the row's combined element. Fall+    // back to the item's own note when the caller doesn't supply one (keeps+    // the older two-arg call sites and tests working).+    let resolvedNote = note ?? item.note+    if let resolvedNote, !resolvedNote.isEmpty {+      parts.append("note: \(resolvedNote)")+    }     return parts.joined(separator: ", ")   } @@ -445,3 +597,20 @@ private struct SkipRestoreAccessibilityAction: ViewModifier {     }   } }++/// Req 8.2 — row-level "Add sub-item" custom action, present only on+/// interactive rows that are below the count cap. VoiceOver users get a+/// generic placeholder entry they can then rename/remove; the sighted path+/// uses the inline reveal-on-tap field.+private struct AddSubItemAccessibilityAction: ViewModifier {+  let enabled: Bool+  let onAdd: () -> Void++  func body(content: Content) -> some View {+    if enabled {+      content.accessibilityAction(named: Text("Add sub-item")) { onAdd() }+    } else {+      content+    }+  }+}
Scramble/Scramble/Components/PackingSubItemsView.swift Added +367 / -0
diff --git a/Scramble/Scramble/Components/PackingSubItemsView.swift b/Scramble/Scramble/Components/PackingSubItemsView.swiftnew file mode 100644index 0000000..5f60e6b--- /dev/null+++ b/Scramble/Scramble/Components/PackingSubItemsView.swift@@ -0,0 +1,367 @@+import SwiftUI++#if canImport(UIKit)+  import UIKit+#endif++/// Inline note + sub-item list renderer for a `PackingItemRow` (feature+/// `packing-item-subitems`, design § "Components and Interfaces →+/// PackingSubItemsView"). Purely presentational: it takes plain values, never+/// an `@Model` reference, so its only refresh trigger is the parent+/// re-rendering (`PackingItemRow` reads `item.note` / `item.subItems`+/// directly so SwiftData establishes observation — an inbound sync re-renders+/// the row, which re-seeds this view).+///+/// Layout, top to bottom:+///   - the note (secondary text; tappable → `onEditNote` on interactive rows),+///   - the sub-item rows (each with a Remove control on interactive rows),+///   - the inline add field, shown only while the parent row's `+` button+///     (left of Skip) has set `isAddFieldVisible` (interactive rows only,+///     suppressed once `subItems.count == PackingSubItems.maxCount`).+///+/// Empty + non-interactive ⇒ renders nothing; empty + interactive with the+/// add field closed ⇒ also renders nothing, so a sub-item-less row stays a+/// single line (the `+` lives in `PackingItemRow`, not here).+struct PackingSubItemsView: View {+  let note: String?+  let subItems: [String]+  /// `== !SheetGroup.isReadOnly`. Gates the note-edit tap, the per-entry+  /// Remove control, and the add affordance (Req 5.2 / 5.4 / Decision 4).+  let isInteractive: Bool+  /// Person colour, used as the accent for the add affordance and Remove+  /// controls (matches the sheet's person-colour semantics).+  let accent: Color+  let onAdd: (String) -> Void+  /// Removes the sub-item at the given list index (by position, not value,+  /// because duplicates are allowed — Req 2.6 / 3.2).+  let onRemove: (Int) -> Void+  /// Saves the inline-edited note (raw text; the sheet applies `sanitizedNote`).+  let onSaveNote: (String) -> Void++  @Environment(\.theme) private var theme+  @Environment(\.colorScheme) private var colorScheme++  /// Row-local mirror of `subItems`, giving each entry a stable identity for+  /// `ForEach` (a flat `[String]` with duplicates has no natural stable id and+  /// `id: \.self` would collapse / mis-target duplicate rows — design §+  /// "List identity"). Re-seeded on `.onChange(of: subItems)` (e.g. an inbound+  /// sync). The stored model stays `[String]` — no persisted ids.+  @State private var drafts: [SubItemDraft]+  /// Controlled by the parent row's `+` button (left of Skip) so the add+  /// field reveals from the trailing controls instead of a persistent+  /// affordance row under the item.+  @Binding private var isAddFieldVisible: Bool+  @State private var addText = ""+  @FocusState private var addFieldFocused: Bool+  /// Controlled by the parent row's note glyph. When true, the note renders as+  /// an editable field (seeded from `note`) instead of static text.+  @Binding private var isEditingNote: Bool+  @State private var noteDraft = ""+  @FocusState private var noteFocused: Bool++  /// One sub-item entry plus a stable id for `ForEach`. There is no inline+  /// rename, so re-seeding on sync never discards an in-progress edit.+  private struct SubItemDraft: Identifiable, Equatable {+    let id = UUID()+    let text: String+  }++  init(+    note: String?,+    subItems: [String],+    isInteractive: Bool,+    accent: Color,+    isAddFieldVisible: Binding<Bool>,+    isEditingNote: Binding<Bool>,+    onAdd: @escaping (String) -> Void,+    onRemove: @escaping (Int) -> Void,+    onSaveNote: @escaping (String) -> Void+  ) {+    self.note = note+    self.subItems = subItems+    self.isInteractive = isInteractive+    self.accent = accent+    _isAddFieldVisible = isAddFieldVisible+    _isEditingNote = isEditingNote+    self.onAdd = onAdd+    self.onRemove = onRemove+    self.onSaveNote = onSaveNote+    _drafts = State(initialValue: subItems.map { SubItemDraft(text: $0) })+  }++  var body: some View {+    let variant = theme.variant(for: colorScheme)++    VStack(alignment: .leading, spacing: 6) {+      noteSection(variant: variant)+      subItemList(variant: variant)+      addAffordance(variant: variant)+    }+    .onChange(of: subItems) { _, newValue in+      drafts = newValue.map { SubItemDraft(text: $0) }+    }+    .onChange(of: isAddFieldVisible) { _, visible in+      // Reveal originates from the parent's list glyph; seed + focus the field+      // here when it becomes visible. The parent owns visibility reporting to+      // the sheet (one combined source of truth), so this view no longer fires+      // a per-binding callback.+      if visible {+        addText = ""+        addFieldFocused = true+      }+    }+    .onChange(of: isEditingNote) { _, editing in+      // The note glyph drives reveal; seed the draft from the current note and+      // focus when it opens.+      if editing {+        noteDraft = note ?? ""+        noteFocused = true+      }+    }+  }++  // MARK: - Note++  /// The note region: an editable field while the note glyph has opened it,+  /// otherwise the static (tappable-to-edit) note text when a note exists.+  @ViewBuilder+  private func noteSection(variant: ThemeVariant) -> some View {+    if isEditingNote {+      noteEditor(variant: variant)+    } else if let note, !note.isEmpty {+      noteText(note, variant: variant)+    }+  }++  @ViewBuilder+  private func noteText(_ note: String, variant: ThemeVariant) -> some View {+    Text(note)+      .font(.subheadline)+      .italic()+      .foregroundStyle(variant.textSecondary)+      .fixedSize(horizontal: false, vertical: true)+      .frame(maxWidth: .infinity, alignment: .leading)+      .contentShape(Rectangle())+      .modifier(NoteTapModifier(enabled: isInteractive, onEditNote: revealNoteEditor))+      .accessibilityLabel("Note: \(note)")+      .accessibilityAddTraits(isInteractive ? .isButton : [])+      #if DEBUG+        .accessibilityIdentifier("packingSubItems.note")+      #endif+  }++  @ViewBuilder+  private func noteEditor(variant: ThemeVariant) -> some View {+    TextField("Note", text: $noteDraft, axis: .vertical)+      .font(.subheadline)+      .foregroundStyle(variant.textPrimary)+      .focused($noteFocused)+      .lineLimit(1...4)+      .padding(.vertical, 8)+      .padding(.horizontal, 10)+      .frame(minHeight: 44)+      .background(+        RoundedRectangle(cornerRadius: 8, style: .continuous)+          .fill(variant.surface)+      )+      .overlay(+        RoundedRectangle(cornerRadius: 8, style: .continuous)+          .strokeBorder(accent.opacity(0.6), lineWidth: 1)+      )+      .onChange(of: noteDraft) { _, new in+        // Live 500-grapheme cap (mirrors the edit form's note cap).+        let capped = PackingSubItems.cappedNote(new)+        if capped != new { noteDraft = capped }+      }+      .onChange(of: noteFocused) { _, focused in+        // Save on blur — the field collapses and the note persists through the+        // sheet's `saveNote` chokepoint.+        if !focused {+          onSaveNote(noteDraft)+          isEditingNote = false+        }+      }+      #if DEBUG+        .accessibilityIdentifier("packingSubItems.noteField")+      #endif+  }++  // MARK: - Sub-item list++  @ViewBuilder+  private func subItemList(variant: ThemeVariant) -> some View {+    if !drafts.isEmpty {+      VStack(alignment: .leading, spacing: 4) {+        ForEach(drafts) { draft in+          subItemRow(draft, variant: variant)+        }+      }+      .accessibilityElement(children: .contain)+    }+  }++  @ViewBuilder+  private func subItemRow(_ draft: SubItemDraft, variant: ThemeVariant) -> some View {+    // Centre-aligned so the bullet and the 44pt Remove button line up with the+    // entry text (top alignment left the Remove glyph sitting below the line).+    HStack(alignment: .center, spacing: 8) {+      Text("•")+        .font(.subheadline)+        .foregroundStyle(variant.textSecondary)+        .accessibilityHidden(true)++      // The text is the entry's addressable element: labelled "sub-item: …"+      // and carrying the per-entry Remove custom action (Req 8.2). The visible+      // remove button is a separate sibling so it stays tappable by pointer /+      // UI tests without being flattened into the text element.+      Text(draft.text)+        .font(.subheadline)+        .foregroundStyle(variant.textSecondary)+        .fixedSize(horizontal: false, vertical: true)+        .frame(maxWidth: .infinity, alignment: .leading)+        .accessibilityElement(children: .combine)+        .accessibilityLabel("sub-item: \(draft.text)")+        .modifier(+          RemoveAccessibilityAction(+            enabled: isInteractive,+            onRemove: { removeDraft(draft) }+          )+        )+        #if DEBUG+          .accessibilityIdentifier("packingSubItems.entry.\(draft.text)")+        #endif++      if isInteractive {+        removeButton(for: draft, variant: variant)+      }+    }+  }++  @ViewBuilder+  private func removeButton(for draft: SubItemDraft, variant: ThemeVariant) -> some View {+    Button {+      removeDraft(draft)+    } label: {+      Image(systemName: "minus.circle")+        .font(.system(size: 13, weight: .semibold))+        .foregroundStyle(accent)+        .frame(width: 44, height: 44)+        .contentShape(Rectangle())+    }+    .buttonStyle(.plain)+    .accessibilityLabel("Remove \(draft.text)")+    #if DEBUG+      .accessibilityIdentifier("packingSubItems.remove.\(draft.text)")+    #endif+  }++  // MARK: - Add affordance++  /// The inline add field, shown only while the parent's `+` button has+  /// revealed it (`isAddFieldVisible`). The persistent "add item" affordance+  /// row was removed in favour of the trailing `+` so an item with no+  /// sub-items keeps a single-line row.+  @ViewBuilder+  private func addAffordance(variant: ThemeVariant) -> some View {+    if isInteractive && subItems.count < PackingSubItems.maxCount && isAddFieldVisible {+      addField(variant: variant)+    }+  }++  @ViewBuilder+  private func addField(variant: ThemeVariant) -> some View {+    TextField("Add sub-item", text: $addText)+      .font(.subheadline)+      .foregroundStyle(variant.textPrimary)+      .focused($addFieldFocused)+      .submitLabel(.done)+      .padding(.vertical, 8)+      .padding(.horizontal, 10)+      .frame(minHeight: 44)+      .background(+        RoundedRectangle(cornerRadius: 8, style: .continuous)+          .fill(variant.surface)+      )+      .overlay(+        RoundedRectangle(cornerRadius: 8, style: .continuous)+          .strokeBorder(accent.opacity(0.6), lineWidth: 1)+      )+      .onChange(of: addText) { _, new in+        // Live 200-grapheme cap, mirroring `PackingItemForm.cappedName`.+        let capped = PackingSubItems.cappedEntry(new)+        if capped != new { addText = capped }+      }+      .onSubmit(submitAddField)+      .onChange(of: addFieldFocused) { _, focused in+        // Blur dismisses the field (design § "Focus / keyboard / dismissal").+        if !focused { isAddFieldVisible = false }+      }+      #if DEBUG+        .accessibilityIdentifier("packingSubItems.addField")+      #endif+  }++  // MARK: - Actions++  /// Tapping the static note text opens the inline editor (same path as the+  /// note glyph). Closes the sub-item add field for one-editor-at-a-time.+  private func revealNoteEditor() {+    isAddFieldVisible = false+    isEditingNote = true+  }++  /// Appends the typed entry (if non-empty) and keeps the field open for rapid+  /// multi-add (design § "Density"). An empty submit dismisses the field.+  private func submitAddField() {+    let trimmed = addText.trimmingCharacters(in: .whitespacesAndNewlines)+    guard !trimmed.isEmpty else {+      isAddFieldVisible = false+      addFieldFocused = false+      return+    }+    onAdd(addText)+    addText = ""+    // Stay open and focused for the next entry.+    addFieldFocused = true+  }++  /// Maps the draft to its current position in `subItems` and removes by index+  /// (positional, so duplicates target the right row — Req 2.6 / 3.2).+  private func removeDraft(_ draft: SubItemDraft) {+    guard let index = drafts.firstIndex(of: draft) else { return }+    onRemove(index)+  }+}++// MARK: - Conditional modifiers++/// The note is tappable-to-edit only on interactive rows; on read-only rows it+/// displays as plain text (Decision 4 / 13).+private struct NoteTapModifier: ViewModifier {+  let enabled: Bool+  let onEditNote: () -> Void++  func body(content: Content) -> some View {+    if enabled {+      content.onTapGesture { onEditNote() }+    } else {+      content+    }+  }+}++/// Per-entry VoiceOver Remove action, present only on interactive rows+/// (Req 8.2). The visible Remove button is `accessibilityHidden` so the+/// custom action is the single addressable removal path per entry.+private struct RemoveAccessibilityAction: ViewModifier {+  let enabled: Bool+  let onRemove: () -> Void++  func body(content: Content) -> some View {+    if enabled {+      content.accessibilityAction(named: Text("Remove")) { onRemove() }+    } else {+      content+    }+  }+}
Scramble/Scramble/Features/Trips/PackingSheet.swift Modified +63 / -6
diff --git a/Scramble/Scramble/Features/Trips/PackingSheet.swift b/Scramble/Scramble/Features/Trips/PackingSheet.swiftindex d5fe4a5..0911ada 100644--- a/Scramble/Scramble/Features/Trips/PackingSheet.swift+++ b/Scramble/Scramble/Features/Trips/PackingSheet.swift@@ -42,6 +42,10 @@ struct PackingSheet: View {   @Environment(\.colorScheme) private var colorScheme    @State private var openDisclosureItemID: UUID?+  /// Identity of the row whose inline sub-item add field is currently+  /// revealed, so the background tap-catcher can be mounted to dismiss it+  /// (design § "Focus / keyboard / dismissal"). Reported by `PackingItemRow`.+  @State private var activeAddFieldItemID: UUID?   @State private var pendingForm: PackingItemFormPresentation?   @AccessibilityFocusState private var headerFocused: Bool @@ -86,6 +90,7 @@ struct PackingSheet: View {                 mode: mode,                 personColour: personColour,                 openDisclosureItemID: $openDisclosureItemID,+                activeAddFieldItemID: $activeAddFieldItemID,                 onEdit: { item in                   pendingForm = .edit(item: item)                 }@@ -116,13 +121,19 @@ struct PackingSheet: View {         }       }       .background {-        // Disclosure-dismiss tap target, mounted only when a disclosure is-        // open. Lives on the background so it can't intercept taps destined-        // for checkboxes, action buttons, or the close button.-        if openDisclosureItemID != nil {+        // Disclosure / add-field dismiss tap target, mounted only when a+        // disclosure or an inline add field is open. Lives on the background+        // so it can't intercept taps destined for checkboxes, action buttons,+        // or the close button. Ending editing makes the active add field lose+        // focus, which collapses it (design § "Focus / keyboard / dismissal").+        if openDisclosureItemID != nil || activeAddFieldItemID != nil {           Color.clear             .contentShape(Rectangle())-            .onTapGesture { openDisclosureItemID = nil }+            .onTapGesture {+              openDisclosureItemID = nil+              activeAddFieldItemID = nil+              endTextEditing()+            }         }       }     }@@ -159,6 +170,16 @@ struct PackingSheet: View {       onDismiss()     }   }++  /// Resigns first responder app-wide so the inline add field loses focus and+  /// self-collapses. No-op off UIKit.+  private func endTextEditing() {+    #if canImport(UIKit)+      UIApplication.shared.sendAction(+        #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil+      )+    #endif+  } }  // MARK: - PackingSheetHeader@@ -244,6 +265,7 @@ private struct PackingItemGroup: View {   let mode: PackingMode   let personColour: Color   @Binding var openDisclosureItemID: UUID?+  @Binding var activeAddFieldItemID: UUID?   let onEdit: (TripPackingItem) -> Void    @Environment(\.modelContext) private var modelContext@@ -272,7 +294,13 @@ private struct PackingItemGroup: View {           onToggleState: { toggleState(item) },           onSkipOrRestore: { skipOrRestore(item) },           onLongPress: { toggleDisclosure(item) },-          onEdit: { onEdit(item) }+          onEdit: { onEdit(item) },+          onSaveNote: { raw in saveNote(item, raw) },+          onAddSubItem: { raw in addSubItem(item, raw) },+          onRemoveSubItem: { index in removeSubItem(item, at: index) },+          onAddFieldVisibilityChanged: { visible in+            activeAddFieldItemID = visible ? item.id : nil+          }         )       }     }@@ -312,6 +340,35 @@ private struct PackingItemGroup: View {     openDisclosureItemID = (openDisclosureItemID == item.id) ? nil : item.id   } +  /// Appends a sub-item via the pure `PackingSubItems` helper, then commits+  /// through the same `hook.commit` chokepoint as the checkbox/skip+  /// mutations. The cap / empty guards reject *before* any write (Decision 9),+  /// and adding never changes the item's state or group (Req 2.5).+  private func addSubItem(_ item: TripPackingItem, _ raw: String) {+    switch PackingSubItems.appending(raw, to: item.subItems) {+    case .added(let list):+      item.subItems = list+      save("addSubItem")+    case .rejectedEmpty, .rejectedFull:+      return+    }+  }++  /// Removes the sub-item at `index` (positional, Req 3.2) and commits.+  /// Removing never changes the item's state (Req 3.3).+  private func removeSubItem(_ item: TripPackingItem, at index: Int) {+    item.subItems = PackingSubItems.removing(at: index, from: item.subItems)+    save("removeSubItem")+  }++  /// Saves the inline-edited note via the same `sanitizedNote` semantics as the+  /// edit form (trim + 500-cap, nil on empty) and commits through the hook+  /// chokepoint. Never changes the item's state or group.+  private func saveNote(_ item: TripPackingItem, _ raw: String) {+    item.note = PackingSubItems.sanitizedNote(raw)+    save("saveNote")+  }+   private func save(_ marker: String) {     do {       try hook.commit(modelContext)
Scramble/Scramble/Features/Trips/PackingItemForm.swift Modified +38 / -6
diff --git a/Scramble/Scramble/Features/Trips/PackingItemForm.swift b/Scramble/Scramble/Features/Trips/PackingItemForm.swiftindex 8436fda..efdf80f 100644--- a/Scramble/Scramble/Features/Trips/PackingItemForm.swift+++ b/Scramble/Scramble/Features/Trips/PackingItemForm.swift@@ -42,6 +42,7 @@ struct PackingItemForm: View {   @Environment(\.localWriteHook) private var hook    @State private var name: String = ""+  @State private var note: String = ""   @State private var inlineError: String?    private static let nameLimit = 200@@ -55,6 +56,17 @@ struct PackingItemForm: View {               let capped = Self.cappedName(new)               if capped != new { name = capped }             }+          TextField("Note (optional)", text: $note, axis: .vertical)+            .lineLimit(1...4)+            .onChange(of: note) { _, new in+              // Live 500-grapheme cap (Req 4.4); trimming to nil happens at+              // save via PackingSubItems.sanitizedNote.+              let capped = Self.cappedNote(new)+              if capped != new { note = capped }+            }+            #if DEBUG+              .accessibilityIdentifier("packingItemForm.noteField")+            #endif         }          if let inlineError {@@ -97,8 +109,10 @@ struct PackingItemForm: View {     switch mode {     case .add:       name = ""+      note = ""     case .edit(let item):       name = item.name+      note = item.note ?? ""     }     inlineError = nil   }@@ -110,10 +124,13 @@ struct PackingItemForm: View {       switch mode {       case .add(let person, let trip):         _ = try Self.performAdd(-          name: name, person: person, trip: trip, context: modelContext, hook: hook+          name: name, note: note, person: person, trip: trip,+          context: modelContext, hook: hook         )       case .edit(let item):-        try Self.performEdit(item: item, name: name, context: modelContext, hook: hook)+        try Self.performEdit(+          item: item, name: name, note: note, context: modelContext, hook: hook+        )       }       onSave()     } catch {@@ -141,6 +158,15 @@ extension PackingItemForm {     String(input.prefix(nameLimit))   } +  /// Live length cap for the note field: truncates to+  /// `PackingSubItems.maxNoteLength` (500) graphemes without trimming, so the+  /// user can still type interior / trailing whitespace while composing+  /// (Req 4.4). Trimming to `nil` for an empty note happens at save via+  /// `PackingSubItems.sanitizedNote`.+  static func cappedNote(_ input: String) -> String {+    String(input.prefix(PackingSubItems.maxNoteLength))+  }+   /// Inserts a manual `TripPackingItem` with the documented field values   /// (Req 5.3). Phase 5.1: writes the V3 `personSnapshotID` value   /// reference (looked up against `trip.participantSnapshots` by@@ -153,6 +179,7 @@ extension PackingItemForm {   @discardableResult   static func performAdd(     name: String,+    note: String = "",     person: Person,     trip: Trip,     context: ModelContext,@@ -183,7 +210,8 @@ extension PackingItemForm {       source: .manual,       currentlyMatchesRules: true,       pinnedByUser: false,-      personSnapshot: snapshot+      personSnapshot: snapshot,+      note: PackingSubItems.sanitizedNote(note)     )     context.insert(item)     do {@@ -195,18 +223,22 @@ extension PackingItemForm {     return item   } -  /// Renames an existing `TripPackingItem`. On save failure the catch path-  /// calls `context.rollback()` so the `@Model` instance reverts to its-  /// pre-edit name (SwiftData does not auto-rollback in-memory edits).+  /// Renames an existing `TripPackingItem` and sets its note from `note`+  /// (`PackingSubItems.sanitizedNote` ⇒ `nil` when empty, Req 4.3). On save+  /// failure the catch path calls `context.rollback()` so the `@Model`+  /// instance reverts to its pre-edit values (SwiftData does not auto-rollback+  /// in-memory edits).   @MainActor   static func performEdit(     item: TripPackingItem,     name: String,+    note: String = "",     context: ModelContext,     hook: LocalWriteHook   ) throws {     let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)     item.name = trimmed+    item.note = PackingSubItems.sanitizedNote(note)     do {       try hook.commit(context)     } catch {
Scramble/Scramble/Models/PackingSubItems.swift Added +90 / -0
diff --git a/Scramble/Scramble/Models/PackingSubItems.swift b/Scramble/Scramble/Models/PackingSubItems.swiftnew file mode 100644index 0000000..66d2b89--- /dev/null+++ b/Scramble/Scramble/Models/PackingSubItems.swift@@ -0,0 +1,90 @@+import Foundation++/// Pure, value-level validation / mutation helpers for a packing item's+/// free-form note and appendable sub-item list (design § "Components and+/// Interfaces → PackingSubItems"). Deliberately free of any view or+/// `ModelContext` dependency so the rules are unit-testable in isolation and+/// reused identically by the inline quick-add (`PackingItemGroup`) and the+/// note field (`PackingItemForm`).+///+/// Caps:+///   - `maxItemLength` (200) — one sub-item entry (Req 2.4), matching the+///     existing `PackingItemForm.nameLimit` item-name cap.+///   - `maxNoteLength` (500) — the note (Req 4.4).+///   - `maxCount` (50) — sub-items per item (Req 2.7 / Decision 8), enforced+///     inline at the point of entry so the over-limit case is never deferred+///     to a sync-time blob-size failure (Decision 9).+///+/// All length caps count grapheme clusters via `String.prefix`, so+/// multi-scalar emoji (ZWJ families, flags, skin-tone) survive intact at the+/// boundary — the same convention as `PackingItemForm.cappedName`.+nonisolated enum PackingSubItems {+  static let maxCount = 50  // Req 2.7 / Decision 8+  static let maxItemLength = 200  // Req 2.4 (grapheme clusters)+  static let maxNoteLength = 500  // Req 4.4 (grapheme clusters)++  /// The outcome of attempting to append a sub-item. The `.added` payload is+  /// the new list (existing entries unchanged, the sanitized entry appended);+  /// the rejection cases carry no payload — the caller leaves the list as-is+  /// and performs no write (Decision 9).+  enum AddOutcome: Equatable {+    case added([String])+    case rejectedEmpty+    case rejectedFull+  }++  /// Trims surrounding whitespace / newlines and caps to `maxItemLength`+  /// grapheme clusters. Returns an empty string for blank input.+  static func sanitizedEntry(_ raw: String) -> String {+    let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)+    return String(trimmed.prefix(maxItemLength))+  }++  /// Live length cap for the inline add field: caps to `maxItemLength`+  /// grapheme clusters *without* trimming, so the user can still type interior+  /// or trailing spaces while composing an entry. Mirrors+  /// `PackingItemForm.cappedName` (prefix-only). The trimming happens later in+  /// `appending`/`sanitizedEntry` at submit time.+  static func cappedEntry(_ raw: String) -> String {+    String(raw.prefix(maxItemLength))+  }++  /// Live length cap for the inline note field: caps to `maxNoteLength`+  /// grapheme clusters without trimming, so interior / trailing spaces survive+  /// while composing. Trimming + nil-on-empty happens in `sanitizedNote` at+  /// save time.+  static func cappedNote(_ raw: String) -> String {+    String(raw.prefix(maxNoteLength))+  }++  /// Appends a sanitized entry to `list`. Returns `.rejectedEmpty` if the+  /// input is blank, `.rejectedFull` if `list` already holds `maxCount`+  /// entries, otherwise `.added` with the entry appended. Empty is checked+  /// before full so blank input at the cap reports `.rejectedEmpty`.+  /// Duplicates are preserved — no de-dup (Req 2.6).+  static func appending(_ raw: String, to list: [String]) -> AddOutcome {+    let entry = sanitizedEntry(raw)+    guard !entry.isEmpty else { return .rejectedEmpty }+    guard list.count < maxCount else { return .rejectedFull }+    return .added(list + [entry])+  }++  /// Removes the entry at `index`, preserving the order of the rest. Removal+  /// is by position, not value, because duplicates are allowed (Req 2.6). An+  /// out-of-range index is a no-op (returns `list` unchanged).+  static func removing(at index: Int, from list: [String]) -> [String] {+    guard list.indices.contains(index) else { return list }+    var copy = list+    copy.remove(at: index)+    return copy+  }++  /// Trims surrounding whitespace / newlines and caps to `maxNoteLength`+  /// grapheme clusters. Returns `nil` for blank input so a cleared note+  /// stores no value (Req 4.3).+  static func sanitizedNote(_ raw: String) -> String? {+    let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)+    guard !trimmed.isEmpty else { return nil }+    return String(trimmed.prefix(maxNoteLength))+  }+}
Scramble/Scramble/Models/TripPackingItem.swift Modified +43 / -1
diff --git a/Scramble/Scramble/Models/TripPackingItem.swift b/Scramble/Scramble/Models/TripPackingItem.swiftindex d7a20aa..ed6860c 100644--- a/Scramble/Scramble/Models/TripPackingItem.swift+++ b/Scramble/Scramble/Models/TripPackingItem.swift@@ -37,6 +37,20 @@ final class TripPackingItem {   /// V3 — see `Trip.ckRecordSystemFields`.   var ckRecordSystemFields: Data? +  /// Per-trip free-form note (feature `packing-item-subitems`). `nil` or+  /// empty ⇒ no note. Optional with a `nil` default so it rides on the+  /// existing `SchemaV3` via lightweight inference — a property-only+  /// addition to a shared top-level class cannot be a distinct+  /// `SchemaV4` (see `persistence.md`).+  var note: String?++  /// Per-trip sub-item list (feature `packing-item-subitems`), JSON-encoded+  /// `[String]`. `nil`/empty ⇒ no sub-items. Optional with a `nil` default+  /// so it rides on `SchemaV3` (same reason as `note`). Read/write through+  /// the `subItems` `CodableBridge` extension below — never as a stored+  /// relationship.+  var subItemsData: Data?+   init(     id: UUID = UUID(),     trip: Trip? = nil,@@ -47,7 +61,8 @@ final class TripPackingItem {     source: ItemSource = .manual,     currentlyMatchesRules: Bool = true,     pinnedByUser: Bool = false,-    personSnapshot: TripPersonSnapshot? = nil+    personSnapshot: TripPersonSnapshot? = nil,+    note: String? = nil   ) {     self.id = id     self.trip = trip@@ -59,6 +74,7 @@ final class TripPackingItem {     self.currentlyMatchesRules = currentlyMatchesRules     self.pinnedByUser = pinnedByUser     self.personSnapshotID = personSnapshot?.id+    self.note = note   } } @@ -73,6 +89,32 @@ extension TripPackingItem {     set { sourceRaw = newValue.rawValue }   } +  /// Bridge over the `subItemsData` JSON blob (feature+  /// `packing-item-subitems`). Lives in an extension so the `@Model` macro+  /// never treats it as a stored relationship. Invariant: an empty list ⇒+  /// no sub-items, treated identically whether `subItemsData` is `nil` or a+  /// non-nil empty `Data()` — the getter normalises empty `Data()` to `[]`+  /// (`CodableBridge.encode` returns empty `Data()`, never nil, on its+  /// degrade path), and the setter stores `nil` for an empty list. Order is+  /// array order (Req 1.3).+  var subItems: [String] {+    get {+      guard let data = subItemsData, !data.isEmpty else { return [] }+      return CodableBridge.decode(+        data,+        as: [String].self,+        default: [],+        label: "TripPackingItem.subItems"+      )+    }+    set {+      subItemsData =+        newValue.isEmpty+        ? nil+        : CodableBridge.encode(newValue, label: "TripPackingItem.subItems")+    }+  }+   /// Bridge over the `personSnapshotID` value reference. Lives in an   /// extension so the `@Model` macro never treats it as a stored   /// relationship (it has no inverse to CloudKit — that is the whole
Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swift Modified +36 / -0
diff --git a/Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swift b/Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swiftindex b72a432..ff667a9 100644--- a/Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swift+++ b/Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swift@@ -32,9 +32,38 @@ enum TripPackingItemRecordTranslator {     record["sourceRaw"] = item.sourceRaw as CKRecordValue     record["currentlyMatchesRules"] = item.currentlyMatchesRules as CKRecordValue     record["pinnedByUser"] = item.pinnedByUser as CKRecordValue+    // Feature `packing-item-subitems`. Assigning `CKRecordValue?(nil)`+    // clears the field so a user who clears the note propagates the+    // deletion to other devices (Req 6.5, Decision 10/12 — the+    // `masterItemID` / `countryCode` clear-propagation precedent).+    record["note"] = item.note as CKRecordValue?+    // Write the sub-item blob only when non-empty. A `nil` or a non-nil+    // empty `Data()` both serialise as an ABSENT field — never a present+    // empty blob — so a cleared list reads back as `[]` on the receiver.+    // This is the first Codable blob on this translator, so the+    // `kRecordBlobSizeCap` guard is net-new here (Decision 8/11): the+    // 50×200 inline caps keep the blob far under the cap, so the throw is+    // effectively unreachable but retained as defence.+    if let subItemsData = item.subItemsData, !subItemsData.isEmpty {+      guard subItemsData.count <= kRecordBlobSizeCap else {+        throw TranslatorError.blobTooLarge(field: "subItemsData", size: subItemsData.count)+      }+      record["subItemsData"] = subItemsData as CKRecordValue+    } else {+      record["subItemsData"] = nil+    }     return record   } +  /// Decode a `CKRecord` into the matching `TripPackingItem`.+  ///+  /// - Important: This requires a FULL server snapshot. Never pass a+  ///   partial / `desiredKeys` record here. `note` and `subItemsData`+  ///   are assigned UNCONDITIONALLY (clear-propagation, Req 6.5 /+  ///   Decision 12), so an absent field is read as "cleared" — a partial+  ///   record would silently wipe local note / sub-items. `CKSyncEngine`'s+  ///   fetch path delivers full records, so this holds today; keep it+  ///   that way if a `desiredKeys` path is ever added.   static func from(_ record: CKRecord, into context: ModelContext) throws {     guard record.recordType == recordType else {       throw TranslatorError.recordTypeMismatch(expected: recordType, actual: record.recordType)@@ -68,6 +97,13 @@ enum TripPackingItemRecordTranslator {       item.currentlyMatchesRules = currentlyMatchesRules     }     if let pinnedByUser = record["pinnedByUser"] as? Bool { item.pinnedByUser = pinnedByUser }+    // Feature `packing-item-subitems`. Unconditional assignment so a+    // clear on the wire reaches this device (Req 6.5 / Decision 12 —+    // diverging from the `if let` sibling fields above, following the+    // `masterItemID` in-file precedent). Relies on the full-snapshot+    // contract documented on this method.+    item.note = record["note"] as? String+    item.subItemsData = record["subItemsData"] as? Data     item.ckRecordSystemFields = encodeSystemFields(of: record)   } 
Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift Modified +7 / -1
diff --git a/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift b/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swiftindex f44f5dc..ec718b8 100644--- a/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift+++ b/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift@@ -367,8 +367,14 @@ final class ZoneMigrationCoordinator {         source: item.source,         currentlyMatchesRules: item.currentlyMatchesRules,         pinnedByUser: item.pinnedByUser,-        personSnapshot: mappedSnapshot+        personSnapshot: mappedSnapshot,+        note: item.note       )+      // Feature `packing-item-subitems`: carry the per-trip sub-item blob+      // across the V2→V3 zone relocation, or the clone silently drops it.+      // `note` is set via the init above; `subItemsData` is copied here+      // because it has no init parameter.+      copiedItem.subItemsData = item.subItemsData       copiedItem.ckRecordSystemFields = item.ckRecordSystemFields       tripsLocalContext.insert(copiedItem)     }
Scramble/ScrambleTests/Models/PackingSubItemsTests.swift Added +332 / -0
diff --git a/Scramble/ScrambleTests/Models/PackingSubItemsTests.swift b/Scramble/ScrambleTests/Models/PackingSubItemsTests.swiftnew file mode 100644index 0000000..4943e8e--- /dev/null+++ b/Scramble/ScrambleTests/Models/PackingSubItemsTests.swift@@ -0,0 +1,332 @@+import Foundation+import Testing++@testable import Scramble++/// Unit + property-based coverage for the pure `PackingSubItems` validation /+/// mutation helpers (design § "Components and Interfaces → PackingSubItems").+/// No view, `@Model`, or `ModelContext` dependency — these are value-level+/// helpers, so the suite is plain (not `@MainActor`).+///+/// Caps under test: `maxItemLength = 200` (Req 2.4), `maxNoteLength = 500`+/// (Req 4.4), `maxCount = 50` (Req 2.7 / Decision 8). All length caps count+/// grapheme clusters via `String.prefix`, so multi-scalar emoji survive+/// intact at the boundary (matches `PackingItemForm.cappedName`).+@Suite("PackingSubItems")+struct PackingSubItemsTests {++  // MARK: - sanitizedEntry++  @Test("sanitizedEntry trims leading / trailing whitespace and newlines")+  func sanitizedEntryTrims() {+    #expect(PackingSubItems.sanitizedEntry("  socks  ") == "socks")+    #expect(PackingSubItems.sanitizedEntry("\n\ttoys\n") == "toys")+    #expect(PackingSubItems.sanitizedEntry("books") == "books")+  }++  @Test("sanitizedEntry returns empty for whitespace-only input")+  func sanitizedEntryWhitespaceOnly() {+    #expect(PackingSubItems.sanitizedEntry("   ").isEmpty)+    #expect(PackingSubItems.sanitizedEntry("\n\t ").isEmpty)+    #expect(PackingSubItems.sanitizedEntry("").isEmpty)+  }++  @Test("sanitizedEntry caps at 200 grapheme clusters")+  func sanitizedEntryCaps() {+    let over = String(repeating: "a", count: PackingSubItems.maxItemLength + 50)+    let capped = PackingSubItems.sanitizedEntry(over)+    #expect(capped.count == PackingSubItems.maxItemLength)+  }++  @Test("sanitizedEntry leaves under-cap input at its own length")+  func sanitizedEntryUnderCap() {+    let entry = String(repeating: "x", count: 10)+    #expect(PackingSubItems.sanitizedEntry(entry).count == 10)+  }++  /// Multi-scalar emoji (ZWJ family, flag, skin-tone) must never be split at+  /// the grapheme boundary. `String.prefix` counts grapheme clusters, so a+  /// string of N identical emoji caps to exactly `maxItemLength` clusters with+  /// every cluster intact.+  @Test(+    "sanitizedEntry keeps multi-scalar emoji intact at the cap boundary",+    arguments: ["👨‍👩‍👧‍👦", "🇳🇱", "👍🏽", "🏳️‍🌈"]+  )+  func sanitizedEntryEmojiBoundary(emoji: String) {+    // One extra cluster beyond the cap, all identical emoji.+    let over = String(repeating: emoji, count: PackingSubItems.maxItemLength + 1)+    let capped = PackingSubItems.sanitizedEntry(over)+    #expect(capped.count == PackingSubItems.maxItemLength)+    // Every retained cluster is the whole emoji — none was split mid-scalar.+    #expect(capped.allSatisfy { String($0) == emoji })+  }++  // MARK: - appending++  @Test("appending rejects empty / whitespace-only input")+  func appendingRejectsEmpty() {+    #expect(PackingSubItems.appending("", to: []) == .rejectedEmpty)+    #expect(PackingSubItems.appending("   ", to: ["a"]) == .rejectedEmpty)+    #expect(PackingSubItems.appending("\n\t", to: ["a", "b"]) == .rejectedEmpty)+  }++  @Test("appending adds a sanitized entry to the end")+  func appendingAdds() {+    #expect(PackingSubItems.appending("  socks ", to: []) == .added(["socks"]))+    #expect(+      PackingSubItems.appending("books", to: ["socks"]) == .added(["socks", "books"])+    )+  }++  @Test("appending preserves existing order and the new entry goes last")+  func appendingPreservesOrder() {+    let list = ["a", "b", "c"]+    #expect(PackingSubItems.appending("d", to: list) == .added(["a", "b", "c", "d"]))+  }++  @Test("appending keeps duplicates — no silent de-dup (Req 2.6)")+  func appendingKeepsDuplicates() {+    #expect(PackingSubItems.appending("socks", to: ["socks"]) == .added(["socks", "socks"]))+    let list = ["x", "x"]+    #expect(PackingSubItems.appending("x", to: list) == .added(["x", "x", "x"]))+  }++  @Test("appending caps the new entry at 200 graphemes before adding")+  func appendingCapsEntry() {+    let over = String(repeating: "z", count: PackingSubItems.maxItemLength + 5)+    guard case .added(let list) = PackingSubItems.appending(over, to: []) else {+      Issue.record("expected .added")+      return+    }+    #expect(list.count == 1)+    #expect(list[0].count == PackingSubItems.maxItemLength)+  }++  @Test("appending rejects when the list already holds 50 entries (Req 2.7)")+  func appendingRejectsFull() {+    let full = (0..<PackingSubItems.maxCount).map { "item-\($0)" }+    #expect(full.count == PackingSubItems.maxCount)+    #expect(PackingSubItems.appending("one-more", to: full) == .rejectedFull)+  }++  @Test("appending succeeds at exactly one below the cap (49 → 50)")+  func appendingSucceedsAtBoundary() {+    let almost = (0..<(PackingSubItems.maxCount - 1)).map { "item-\($0)" }+    guard case .added(let list) = PackingSubItems.appending("last", to: almost) else {+      Issue.record("expected .added")+      return+    }+    #expect(list.count == PackingSubItems.maxCount)+  }++  @Test("appending checks empty before full — blank input at the cap is rejectedEmpty")+  func appendingEmptyBeatsFull() {+    let full = (0..<PackingSubItems.maxCount).map { "item-\($0)" }+    #expect(PackingSubItems.appending("   ", to: full) == .rejectedEmpty)+  }++  // MARK: - removing++  @Test("removing deletes only the indexed entry and keeps order")+  func removingDeletesIndex() {+    #expect(PackingSubItems.removing(at: 1, from: ["a", "b", "c"]) == ["a", "c"])+    #expect(PackingSubItems.removing(at: 0, from: ["a", "b", "c"]) == ["b", "c"])+    #expect(PackingSubItems.removing(at: 2, from: ["a", "b", "c"]) == ["a", "b"])+  }++  @Test("removing targets the position, not the value, when duplicates exist")+  func removingTargetsPosition() {+    // Remove the middle "x"; the other two stay.+    #expect(PackingSubItems.removing(at: 1, from: ["x", "x", "x"]) == ["x", "x"])+  }++  @Test(+    "removing is a no-op for an out-of-range index",+    arguments: [-1, 3, 99, Int.max]+  )+  func removingOutOfRangeNoOp(index: Int) {+    let list = ["a", "b", "c"]+    #expect(PackingSubItems.removing(at: index, from: list) == list)+  }++  @Test("removing from an empty list is a no-op")+  func removingFromEmpty() {+    #expect(PackingSubItems.removing(at: 0, from: []).isEmpty)+  }++  // MARK: - sanitizedNote++  @Test("sanitizedNote trims and returns the trimmed text")+  func sanitizedNoteTrims() {+    #expect(PackingSubItems.sanitizedNote("  keep batteries out  ") == "keep batteries out")+  }++  @Test("sanitizedNote returns nil for empty / whitespace-only input (Req 4.3)")+  func sanitizedNoteNilOnEmpty() {+    #expect(PackingSubItems.sanitizedNote("") == nil)+    #expect(PackingSubItems.sanitizedNote("   ") == nil)+    #expect(PackingSubItems.sanitizedNote("\n\t ") == nil)+  }++  @Test("sanitizedNote caps at 500 grapheme clusters")+  func sanitizedNoteCaps() {+    let over = String(repeating: "n", count: PackingSubItems.maxNoteLength + 100)+    let capped = PackingSubItems.sanitizedNote(over)+    #expect(capped?.count == PackingSubItems.maxNoteLength)+  }++  @Test("sanitizedNote keeps multi-scalar emoji intact at the cap boundary")+  func sanitizedNoteEmojiBoundary() {+    let emoji = "👨‍👩‍👧‍👦"+    let over = String(repeating: emoji, count: PackingSubItems.maxNoteLength + 1)+    let capped = PackingSubItems.sanitizedNote(over)+    #expect(capped?.count == PackingSubItems.maxNoteLength)+    #expect(capped?.allSatisfy { String($0) == emoji } == true)+  }++  @Test("cappedNote caps at 500 graphemes without trimming")+  func cappedNoteCaps() {+    let over = String(repeating: "n", count: PackingSubItems.maxNoteLength + 100)+    #expect(PackingSubItems.cappedNote(over).count == PackingSubItems.maxNoteLength)+  }++  @Test("cappedNote preserves interior/trailing whitespace while composing")+  func cappedNotePreservesWhitespace() {+    // Unlike sanitizedNote, the live cap must not trim — the user may still be+    // typing spaces between words.+    let composing = "two words "+    #expect(PackingSubItems.cappedNote(composing) == composing)+  }++  // MARK: - Property-based: encode/decode round-trip++  /// The `subItems` model bridge serialises `[String]` through `CodableBridge`+  /// (design § "Data Models"). The round-trip is a universal serializer+  /// guarantee: `decode(encode(xs)) == xs` for any `[String]`, including+  /// duplicates, empty entries, unicode, and boundary-length entries within+  /// the caps. This locks the bridge encoding stream 2 / stream 1 must share.+  @Test(+    "PBT — decode(encode(xs)) == xs over generated [String]",+    arguments: PackingSubItemsTests.generatedLists()+  )+  func roundTripIdentity(list: [String]) {+    let data = CodableBridge.encode(list, label: "PackingSubItemsTests.roundTrip")+    let decoded = CodableBridge.decode(+      data, as: [String].self, default: [], label: "PackingSubItemsTests.roundTrip"+    )+    #expect(decoded == list)+  }++  // MARK: - Property-based: add / remove length invariants++  /// Over a generated sequence of add / remove operations applied to a+  /// generated starting list, the structural invariants hold:+  ///   - count never exceeds `maxCount` (Req 2.7),+  ///   - a successful `.added` increases length by exactly 1,+  ///   - a non-empty `removing` decreases length by exactly 1 (and is a no-op+  ///     when the index is out of range).+  @Test(+    "PBT — add / remove preserve the length invariants",+    arguments: PackingSubItemsTests.operationSequences()+  )+  func addRemoveInvariants(sequence: OperationSequence) {+    var list = sequence.start+    for op in sequence.ops {+      switch op {+      case .add(let raw):+        let before = list.count+        switch PackingSubItems.appending(raw, to: list) {+        case .added(let next):+          #expect(next.count == before + 1)+          list = next+        case .rejectedEmpty:+          #expect(PackingSubItems.sanitizedEntry(raw).isEmpty)+        case .rejectedFull:+          #expect(before >= PackingSubItems.maxCount)+        }+      case .remove(let index):+        let before = list.count+        let next = PackingSubItems.removing(at: index, from: list)+        if index >= 0 && index < before {+          #expect(next.count == before - 1)+        } else {+          #expect(next == list)+        }+        list = next+      }+      // The cap is never breached, whatever the operation.+      #expect(list.count <= PackingSubItems.maxCount)+    }+  }++  // MARK: - Generators++  /// A spread of `[String]` covering the round-trip edge cases the design+  /// calls out: duplicates, empty strings, unicode / multi-scalar emoji, and+  /// boundary-length entries within the caps.+  static func generatedLists() -> [[String]] {+    let boundary = String(repeating: "a", count: PackingSubItems.maxItemLength)+    let emoji = "👨‍👩‍👧‍👦"+    return [+      [],+      [""],+      ["socks"],+      ["socks", "socks"],+      ["", "", ""],+      ["a", "b", "a", "b"],+      ["café", "naïve", "Zürich"],+      [emoji, "🇳🇱", "👍🏽"],+      [boundary],+      [boundary, "x", boundary],+      ["line1\nline2", "tab\tsep"],+      (0..<PackingSubItems.maxCount).map { "item-\($0)" },+    ]+  }++  enum Operation: Sendable {+    case add(String)+    case remove(Int)+  }++  struct OperationSequence: Sendable, CustomStringConvertible {+    let start: [String]+    let ops: [Operation]+    let label: String++    var description: String { label }+  }++  /// Generated add / remove sequences exercised against several starting+  /// lists, including a list already at the cap so `rejectedFull` is hit.+  static func operationSequences() -> [OperationSequence] {+    let full = (0..<PackingSubItems.maxCount).map { "f-\($0)" }+    let nearFull = (0..<(PackingSubItems.maxCount - 1)).map { "n-\($0)" }+    return [+      OperationSequence(+        start: [],+        ops: [.add("a"), .add("b"), .add("  "), .remove(0), .remove(5)],+        label: "empty-start mixed"+      ),+      OperationSequence(+        start: ["x", "y", "z"],+        ops: [.remove(1), .add("dup"), .add("dup"), .remove(-1), .remove(99)],+        label: "dups + OOR removes"+      ),+      OperationSequence(+        start: full,+        ops: [.add("overflow"), .add("still-full"), .remove(0), .add("now-fits")],+        label: "at-cap rejects then fits after remove"+      ),+      OperationSequence(+        start: nearFull,+        ops: [.add("fills-to-cap"), .add("overflow"), .remove(10)],+        label: "near-cap fills then rejects"+      ),+      OperationSequence(+        start: ["only"],+        ops: [.remove(0), .remove(0), .add("again")],+        label: "drain to empty then add"+      ),+    ]+  }+}
Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift Added +116 / -0
diff --git a/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift b/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swiftnew file mode 100644index 0000000..b505c2a--- /dev/null+++ b/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift@@ -0,0 +1,116 @@+import Foundation+import SwiftData+import Testing++@testable import Scramble++/// Coverage for the `TripPackingItem.note` / `subItems` per-trip content+/// fields (feature `packing-item-subitems`). `subItems` is a `CodableBridge`+/// blob over `subItemsData: Data?` whose invariant is "empty list => no+/// sub-items", treated identically whether `subItemsData` is `nil` or a+/// non-nil empty `Data()`. `note` is a plain `String?`. Both ride on+/// `SchemaV3` as Optionals (no `SchemaV4`).+@Suite("TripPackingItem.note / subItems bridge", .serialized)+@MainActor+struct TripPackingItemContentBridgeTests {++  @Test("subItems get/set round-trips through subItemsData")+  func subItemsRoundTrip() throws {+    let context = try Self.makeContext()+    let item = TripPackingItem(name: "Toys")+    context.insert(item)++    item.subItems = ["dice", "cards", "blocks"]+    try context.save()++    #expect(item.subItems == ["dice", "cards", "blocks"])+    #expect(item.subItemsData != nil)+  }++  @Test("setting subItems = [] clears subItemsData to nil")+  func emptyListClearsData() throws {+    let context = try Self.makeContext()+    let item = TripPackingItem(name: "Books")+    context.insert(item)++    item.subItems = ["one", "two"]+    #expect(item.subItemsData != nil)++    item.subItems = []+    #expect(item.subItemsData == nil)+    #expect(item.subItems == [])+  }++  @Test("non-nil empty Data() reads back as []")+  func nonNilEmptyDataReadsAsEmpty() throws {+    let context = try Self.makeContext()+    let item = TripPackingItem(name: "Snacks")+    context.insert(item)++    // CodableBridge.encode returns empty Data() (never nil) on its degrade+    // path; the getter must normalise that to [].+    item.subItemsData = Data()+    #expect(item.subItems == [])+  }++  @Test("garbage Data decodes to []")+  func garbageDataDecodesToEmpty() throws {+    let context = try Self.makeContext()+    let item = TripPackingItem(name: "Gear")+    context.insert(item)++    item.subItemsData = Data([0x00, 0xFF, 0x42])+    #expect(item.subItems == [])+  }++  @Test("note set then cleared")+  func noteSetThenCleared() throws {+    let context = try Self.makeContext()+    let item = TripPackingItem(name: "Camera")+    context.insert(item)++    item.note = "keep batteries out"+    try context.save()+    #expect(item.note == "keep batteries out")++    item.note = nil+    try context.save()+    #expect(item.note == nil)+  }++  @Test("note via init")+  func noteViaInit() throws {+    let context = try Self.makeContext()+    let item = TripPackingItem(name: "Charger", note: "USB-C only")+    context.insert(item)+    try context.save()+    #expect(item.note == "USB-C only")+  }++  @Test("skip -> restore leaves note and subItems unchanged")+  func skipRestorePreservesContent() throws {+    let context = try Self.makeContext()+    let item = TripPackingItem(name: "Toys", note: "soft ones only")+    context.insert(item)+    item.subItems = ["bear", "blocks"]+    try context.save()++    item.state = .excluded+    try context.save()+    #expect(item.note == "soft ones only")+    #expect(item.subItems == ["bear", "blocks"])++    item.state = .unpacked+    try context.save()+    #expect(item.note == "soft ones only")+    #expect(item.subItems == ["bear", "blocks"])+  }++  private static func makeContext() throws -> ModelContext {+    let schema = Schema(versionedSchema: SchemaV3.self)+    let config = ModelConfiguration(+      schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none+    )+    return try ModelContainer(for: schema, configurations: [config]).mainContext+  }+}
Scramble/ScrambleTests/Features/Trips/PackingFormSaveTests.swift Modified +107 / -0
diff --git a/Scramble/ScrambleTests/Features/Trips/PackingFormSaveTests.swift b/Scramble/ScrambleTests/Features/Trips/PackingFormSaveTests.swiftindex 01fddc9..00b60bd 100644--- a/Scramble/ScrambleTests/Features/Trips/PackingFormSaveTests.swift+++ b/Scramble/ScrambleTests/Features/Trips/PackingFormSaveTests.swift@@ -279,6 +279,113 @@ struct PackingFormSaveTests {     #expect(PackingItemForm.isSubmitEnabled("   Toothbrush   ") == true)   } +  // MARK: - Note field (feature packing-item-subitems, Req 4.2 / 4.3 / 4.4)++  @Test(".add with a note stores the sanitized note on the new item")+  func addStoresSanitizedNote() throws {+    let container = try Self.makeContainer()+    let context = container.mainContext+    let (trip, person) = try Self.seedTripWithPerson(in: context)++    let hook = LocalWriteHook(notifier: RecordingNotifier())+    let inserted = try PackingItemForm.performAdd(+      name: "Toys",+      note: "  keep batteries out  ",+      person: person,+      trip: trip,+      context: context,+      hook: hook+    )++    #expect(inserted.note == "keep batteries out")+  }++  @Test(".add with an empty / whitespace note stores nil (Req 4.3)")+  func addEmptyNoteStoresNil() throws {+    let container = try Self.makeContainer()+    let context = container.mainContext+    let (trip, person) = try Self.seedTripWithPerson(in: context)++    let hook = LocalWriteHook(notifier: RecordingNotifier())+    let inserted = try PackingItemForm.performAdd(+      name: "Toys",+      note: "   \n\t  ",+      person: person,+      trip: trip,+      context: context,+      hook: hook+    )++    #expect(inserted.note == nil)+  }++  @Test(".edit sets the sanitized note on an existing item")+  func editSetsNote() throws {+    let container = try Self.makeContainer()+    let context = container.mainContext+    let (trip, person) = try Self.seedTripWithPerson(in: context)++    let item = TripPackingItem(trip: trip, name: "Toys", personSnapshot: nil)+    _ = person+    context.insert(item)+    try context.save()++    let hook = LocalWriteHook(notifier: RecordingNotifier())+    try PackingItemForm.performEdit(+      item: item,+      name: "Toys",+      note: "  fragile  ",+      context: context,+      hook: hook+    )++    #expect(item.note == "fragile")+  }++  @Test(".edit clearing the note to empty stores nil (Req 4.3)")+  func editClearingNoteStoresNil() throws {+    let container = try Self.makeContainer()+    let context = container.mainContext+    let (trip, _) = try Self.seedTripWithPerson(in: context)++    let item = TripPackingItem(trip: trip, name: "Toys", note: "old note")+    context.insert(item)+    try context.save()+    #expect(item.note == "old note")++    let hook = LocalWriteHook(notifier: RecordingNotifier())+    try PackingItemForm.performEdit(+      item: item, name: "Toys", note: "   ", context: context, hook: hook+    )++    #expect(item.note == nil)+  }++  @Test("cappedNote returns input unchanged at or below 500 characters")+  func cappedNotePassesThroughShortInputs() {+    #expect(PackingItemForm.cappedNote("") == "")+    #expect(PackingItemForm.cappedNote("keep batteries out") == "keep batteries out")+    let exact = String(repeating: "a", count: PackingSubItems.maxNoteLength)+    #expect(PackingItemForm.cappedNote(exact) == exact)+    #expect(PackingItemForm.cappedNote(exact).count == PackingSubItems.maxNoteLength)+  }++  @Test("cappedNote truncates input longer than 500 characters (Req 4.4)")+  func cappedNoteTruncatesLongInputs() {+    let long = String(repeating: "x", count: PackingSubItems.maxNoteLength + 50)+    let capped = PackingItemForm.cappedNote(long)+    #expect(capped.count == PackingSubItems.maxNoteLength)+  }++  @Test("cappedNote preserves grapheme clusters at the boundary")+  func cappedNoteRespectsGraphemeBoundary() {+    let prefix = String(repeating: "a", count: PackingSubItems.maxNoteLength - 1)+    let input = prefix + "👨‍👩‍👧"  // single grapheme cluster+    let capped = PackingItemForm.cappedNote(input)+    #expect(capped.count == PackingSubItems.maxNoteLength)+    #expect(capped.hasSuffix("👨‍👩‍👧"))+  }+   // MARK: - Req 5.5 — 200-char cap enforced at input    @Test("cappedName returns input unchanged when at or below 200 characters")
Scramble/ScrambleTests/Sharing/Translators/TripPackingItemRecordTranslatorTests.swift Modified +102 / -0
diff --git a/Scramble/ScrambleTests/Sharing/Translators/TripPackingItemRecordTranslatorTests.swift b/Scramble/ScrambleTests/Sharing/Translators/TripPackingItemRecordTranslatorTests.swiftindex a0043b0..7474ed6 100644--- a/Scramble/ScrambleTests/Sharing/Translators/TripPackingItemRecordTranslatorTests.swift+++ b/Scramble/ScrambleTests/Sharing/Translators/TripPackingItemRecordTranslatorTests.swift@@ -84,6 +84,108 @@ struct TripPackingItemRecordTranslatorTests {     #expect(stored.state == .unpacked)   } +  // MARK: - note + subItems round-trip (feature packing-item-subitems, Req 6.2)++  @Test("note + subItems survive a toRecord → from round-trip without loss or reorder")+  func noteAndSubItemsRoundTrip() throws {+    let container = try Self.makeContainer()+    let context = container.mainContext+    let zoneID = Self.zoneID()+    let id = UUID()+    let source = TripPackingItem(id: id, name: "Toys", note: "keep batteries out")+    source.subItems = ["lego", "blocks", "lego"]  // duplicate kept (Req 2.6)++    let record = try TripPackingItemRecordTranslator.toRecord(source, in: zoneID)+    try TripPackingItemRecordTranslator.from(record, into: context)+    try context.save()++    let stored = try #require(try context.fetch(FetchDescriptor<TripPackingItem>()).first)+    #expect(stored.note == "keep batteries out")+    // Order preserved exactly, duplicate retained (Req 1.3, 2.6, 6.2).+    #expect(stored.subItems == ["lego", "blocks", "lego"])+  }++  // MARK: - Clear matrix (Req 6.5, Decision 10/12)++  @Test(+    "toRecord serialises {nil, empty Data(), non-empty} subItemsData as {absent, absent, present}")+  func subItemsDataClearMatrixEncodes() throws {+    let zoneID = Self.zoneID()++    let nilItem = TripPackingItem(name: "A")+    nilItem.subItemsData = nil+    let nilRecord = try TripPackingItemRecordTranslator.toRecord(nilItem, in: zoneID)+    #expect(nilRecord["subItemsData"] == nil, "nil ⇒ absent field")++    // A non-nil empty Data() must serialise as an ABSENT field, not an+    // empty blob, so a cleared list reads back as [] on the receiver.+    let emptyItem = TripPackingItem(name: "B")+    emptyItem.subItemsData = Data()+    let emptyRecord = try TripPackingItemRecordTranslator.toRecord(emptyItem, in: zoneID)+    #expect(emptyRecord["subItemsData"] == nil, "empty Data() ⇒ absent field, not empty blob")++    let fullItem = TripPackingItem(name: "C")+    fullItem.subItems = ["x"]+    let fullRecord = try TripPackingItemRecordTranslator.toRecord(fullItem, in: zoneID)+    #expect((fullRecord["subItemsData"] as Any?) is Data, "non-empty ⇒ present blob")+  }++  @Test("toRecord serialises {nil, non-empty} note as {absent, present}")+  func noteClearMatrixEncodes() throws {+    let zoneID = Self.zoneID()++    let nilItem = TripPackingItem(name: "A", note: nil)+    let nilRecord = try TripPackingItemRecordTranslator.toRecord(nilItem, in: zoneID)+    #expect(nilRecord["note"] == nil, "nil note ⇒ absent field")++    let setItem = TripPackingItem(name: "B", note: "hello")+    let setRecord = try TripPackingItemRecordTranslator.toRecord(setItem, in: zoneID)+    #expect(setRecord["note"] as? String == "hello")+  }++  @Test("populated → cleared note propagates nil to the receiver (Req 6.5)")+  func populatedThenClearedNotePropagates() throws {+    let container = try Self.makeContainer()+    let context = container.mainContext+    let zoneID = Self.zoneID()+    let id = UUID()++    // Receiver already holds a populated note + sub-items.+    let existing = TripPackingItem(id: id, name: "Toys", note: "old note")+    existing.subItems = ["a", "b"]+    context.insert(existing)+    try context.save()++    // Sender cleared both — toRecord emits absent fields.+    let cleared = TripPackingItem(id: id, name: "Toys")+    cleared.note = nil+    cleared.subItemsData = nil+    let record = try TripPackingItemRecordTranslator.toRecord(cleared, in: zoneID)++    try TripPackingItemRecordTranslator.from(record, into: context)+    try context.save()++    let stored = try #require(try context.fetch(FetchDescriptor<TripPackingItem>()).first)+    #expect(stored.note == nil, "cleared note must reach the receiver")+    #expect(stored.subItems == [], "cleared list must reach the receiver")+    #expect(stored.subItemsData == nil)+  }++  // MARK: - blobTooLarge guard (Req 6.4)++  @Test("toRecord throws blobTooLarge when subItemsData is forced over the cap")+  func subItemsBlobTooLargeThrows() throws {+    let zoneID = Self.zoneID()+    let item = TripPackingItem(name: "Toys")+    // Force the blob past the cap directly (the inline caps make this+    // unreachable in normal use, but the guard must still fire).+    item.subItemsData = Data(repeating: 0x41, count: kRecordBlobSizeCap + 16)++    #expect(throws: TranslatorError.self) {+      _ = try TripPackingItemRecordTranslator.toRecord(item, in: zoneID)+    }+  }+   // MARK: - Helpers    private static func zoneID() -> CKRecordZone.ID {
Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift Modified +99 / -0
diff --git a/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift b/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swiftindex d5d3dcf..d8e3a54 100644--- a/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift+++ b/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift@@ -312,4 +312,103 @@ struct RulesEngineRunnerTests {     #expect(plans.count == 2)     #expect(Set(plans.map(\.tripID)) == Set([trip1.id, trip2.id]))   }++  // MARK: - packing-item-subitems — rules independence (Req 7.1–7.3) + deletion (7.4)++  /// Bundle returned by `seedMatchedPackingItem` for the independence+  /// assertions (a struct rather than a 4-tuple per SwiftLint).+  private struct MatchedSeed {+    let context: ModelContext+    let trip: Trip+    let person: Person+    let item: TripPackingItem+  }++  /// Seed a rainy trip whose single matched master produces one packing+  /// item, then run the engine once so the item exists.+  private static func seedMatchedPackingItem() throws -> MatchedSeed {+    let container = try makeContainer()+    let context = container.mainContext++    let person = Person(name: "P", colorKey: "blue")+    context.insert(person)+    let masterPack = MasterPackingItem(+      name: "Rain jacket",+      person: person,+      conditions: .match(attribute: .weather, anyOf: ["rain"])+    )+    context.insert(masterPack)+    let trip = Trip(+      name: "Beach", startDate: .now, endDate: .now, attributes: rainyAttributes())+    context.insert(trip)+    let snapshot = TripPersonSnapshot(+      personID: person.id,+      name: person.name,+      colourID: person.colorKey,+      initialSource: "name",+      isRosterMember: true,+      trip: trip+    )+    context.insert(snapshot)+    try context.save()++    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)+  }++  @Test("Req 7.1/7.2: recompute leaves an existing item's note + subItems unchanged")+  func recomputePreservesNoteAndSubItems() throws {+    let seed = try Self.seedMatchedPackingItem()+    seed.item.note = "keep batteries out"+    seed.item.subItems = ["lego", "blocks"]+    try seed.context.save()++    // Re-run the deterministic engine; the matched item already exists, so+    // the plan should be empty and the per-trip content untouched.+    let runner = RulesEngineRunner(context: seed.context)+    let plan = try runner.runForTrip(seed.trip)+    #expect(plan.isEmpty)++    let stored = try #require(try seed.context.fetch(FetchDescriptor<TripPackingItem>()).first)+    #expect(stored.note == "keep batteries out")+    #expect(stored.subItems == ["lego", "blocks"])+  }++  @Test("Req 7.3: note + subItems do not change packing counts or group membership")+  func noteAndSubItemsDoNotAffectCountsOrGroups() throws {+    let seed = try Self.seedMatchedPackingItem()++    let countsBefore = PackingListHelpers.counts(for: seed.person, in: seed.trip)+    let groupBefore = Set(+      PackingListHelpers.itemsForPerson(seed.trip, person: seed.person).map(\.id)+    )++    seed.item.note = "a note"+    seed.item.subItems = ["one", "two", "three"]+    try seed.context.save()++    let countsAfter = PackingListHelpers.counts(for: seed.person, in: seed.trip)+    let groupAfter = Set(+      PackingListHelpers.itemsForPerson(seed.trip, person: seed.person).map(\.id)+    )++    #expect(countsAfter == countsBefore, "Counts must be unaffected by note / sub-items")+    #expect(groupAfter == groupBefore, "Group membership must be unaffected")+  }++  @Test("Req 7.4: deleting a packing item removes its note + subItems with it")+  func deletingItemRemovesNoteAndSubItems() throws {+    let seed = try Self.seedMatchedPackingItem()+    seed.item.note = "keep batteries out"+    seed.item.subItems = ["lego", "blocks"]+    try seed.context.save()++    seed.context.delete(seed.item)+    try seed.context.save()++    let remaining = try seed.context.fetch(FetchDescriptor<TripPackingItem>())+    #expect(remaining.isEmpty, "Item and its note / sub-items are gone")+  } }
Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift Modified +57 / -0
diff --git a/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift b/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swiftindex ca9fe64..c753a8b 100644--- a/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift+++ b/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift@@ -253,6 +253,63 @@ struct ZoneMigrationCoordinatorPhase51Tests {   }   // swiftlint:enable function_body_length +  // MARK: - packing-item-subitems — relocation carries note + subItems++  @Test("relocateTrip carries a packing item's note + subItemsData (feature regression guard)")+  func relocationCarriesNoteAndSubItems() throws {+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()+    let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)+    let snapshot = TripPersonSnapshot(+      personID: UUID(),+      name: "Alice",+      colourID: "cyan",+      initialSource: "manual",+      isRosterMember: true,+      trip: trip+    )+    let item = TripPackingItem(+      trip: trip,+      name: "Toys",+      personSnapshot: snapshot,+      note: "keep batteries out"+    )+    item.subItems = ["lego", "blocks", "lego"]  // duplicate + order preserved+    setup.globalsContext.insert(trip)+    setup.globalsContext.insert(snapshot)+    setup.globalsContext.insert(item)+    try setup.globalsContext.save()++    try setup.coordinator.enqueueAll()+    try setup.coordinator.runStageB()++    let relocated = try #require(+      try setup.tripsLocalContext.fetch(FetchDescriptor<TripPackingItem>()).first+    )+    #expect(relocated.note == "keep batteries out")+    #expect(relocated.subItems == ["lego", "blocks", "lego"])+  }++  @Test("relocateTrip leaves a nil-note nil-subItems item still nil (no empty blob)")+  func relocationNilFieldsStayNil() throws {+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()+    let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)+    let item = TripPackingItem(trip: trip, name: "Towel")+    // Both new fields untouched ⇒ nil.+    setup.globalsContext.insert(trip)+    setup.globalsContext.insert(item)+    try setup.globalsContext.save()++    try setup.coordinator.enqueueAll()+    try setup.coordinator.runStageB()++    let relocated = try #require(+      try setup.tripsLocalContext.fetch(FetchDescriptor<TripPackingItem>()).first+    )+    #expect(relocated.note == nil)+    #expect(relocated.subItemsData == nil, "nil must relocate as nil, not an empty Data()")+    #expect(relocated.subItems == [])+  }+   // MARK: - Phase 5.1 — Stage A snapshot retroactive dirty-flagging (Req 4.9)    @Test("Stage A TripPersonSnapshot rows are retroactively dirty-flagged on Stage B entry")
Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swift Modified +46 / -0
diff --git a/Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swift b/Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swiftindex 607cd55..b489a3d 100644--- a/Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swift+++ b/Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swift@@ -57,6 +57,52 @@ struct SchemaV3MigrationTests {     #expect(stored.countryCode == nil)   } +  // MARK: - packing-item-subitems note + subItems ride on V3 (no SchemaV4)++  @Test("SchemaV3 TripPackingItem entity includes note + subItemsData")+  func schemaV3IncludesNoteAndSubItems() {+    let schema = Schema(versionedSchema: SchemaV3.self)+    let item = schema.entities.first(where: { $0.name == "TripPackingItem" })+    let propertyNames = Set(item?.properties.map(\.name) ?? [])+    #expect(propertyNames.contains("note"))+    #expect(propertyNames.contains("subItemsData"))+  }++  @Test("The two new columns ride on V3 — the plan still exposes exactly two stages (no SchemaV4)")+  func noSchemaV4Bump() {+    // A property-only addition to the shared `TripPackingItem` must NOT+    // introduce a new schema version (a fourth version would trip the+    // "Duplicate version checksums" crash). The plan shape is unchanged:+    // V1, V2, V3 with two consecutive stages.+    #expect(AppMigrationPlan.schemas.count == 3)+    #expect(AppMigrationPlan.stages.count == 2)+  }++  @Test(+    """+    Opening a pre-feature V3-shaped store materialises note + subItemsData as nil \+    (lightweight inference, no duplicate-checksum crash)+    """+  )+  func freshV3PackingItemNoteAndSubItemsNil() throws {+    // The container is built through AppMigrationPlan; a successful open+    // + insert + fetch confirms the lightweight column addition does not+    // crash and surfaces the new columns as nil.+    let container = try Self.makeV3Container()+    let context = container.mainContext++    let trip = Trip(name: "T", startDate: .now, endDate: .now)+    context.insert(trip)+    let item = TripPackingItem(trip: trip, name: "Toys")+    context.insert(item)+    try context.save()++    let stored = try #require(try context.fetch(FetchDescriptor<TripPackingItem>()).first)+    #expect(stored.note == nil)+    #expect(stored.subItemsData == nil)+    #expect(stored.subItems == [])+  }+   // MARK: - Fresh V3 entity defaults    @Test(
Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift Modified +27 / -0
diff --git a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swiftindex 8bbfc15..122119d 100644--- a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift+++ b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift@@ -47,6 +47,33 @@ struct PackingItemRowAccessibilityTests {     #expect(label == "Socks, not bringing, owned by Bob")   } +  @Test("A note is appended to the combined label (Req 8.1)")+  func labelIncludesNote() throws {+    let setup = try Self.makeSetup()+    let trip = Trip(name: "T", startDate: .now, endDate: .now)+    setup.context.insert(trip)+    let item = TripPackingItem(+      trip: trip, name: "Toys", state: .unpacked, note: "keep batteries out"+    )+    setup.context.insert(item)+    try setup.context.save()++    let label = PackingItemRow.composedAccessibilityLabel(+      item: item, group: .stillNeedToPack, note: item.note+    )+    #expect(label == "Toys, not packed, note: keep batteries out")+  }++  @Test("No note clause when the item has none")+  func labelOmitsAbsentNote() throws {+    let setup = try Self.makeSetup()+    let (item, _) = try Self.makeItem(state: .unpacked, owner: "Alice", in: setup.context)+    let label = PackingItemRow.composedAccessibilityLabel(+      item: item, group: .stillNeedToPack, note: nil+    )+    #expect(label == "Socks, not packed, owned by Alice")+  }+   @Test("Item without an owner snapshot omits the 'owned by' clause")   func unownedLabel() throws {     let setup = try Self.makeSetup()
Scramble/ScrambleUITests/PackingSubItemsUITests.swift Added +141 / -0
diff --git a/Scramble/ScrambleUITests/PackingSubItemsUITests.swift b/Scramble/ScrambleUITests/PackingSubItemsUITests.swiftnew file mode 100644index 0000000..daf64a4--- /dev/null+++ b/Scramble/ScrambleUITests/PackingSubItemsUITests.swift@@ -0,0 +1,141 @@+import XCTest++/// `packing-item-subitems` feature — inline sub-item add / remove on a packing+/// row, plus read-only display once the item is moved to a read-only group+/// (Not bringing). Mirrors `PackingSheetUITests`: launches a fresh in-memory+/// container via `-uitest 1 -seed-fixture phase4-pack-mode-trip` and drives the+/// accessibility identifiers emitted by `PackingSubItemsView`.+final class PackingSubItemsUITests: XCTestCase {++  override func setUpWithError() throws {+    continueAfterFailure = false+  }++  // MARK: - Helpers++  @MainActor+  private func launchedApp(fixture: String) -> XCUIApplication {+    let app = XCUIApplication()+    app.launchArguments = ["-uitest", "1", "-seed-fixture", fixture]+    app.launch()+    let marker = app.descendants(matching: .any)+      .matching(identifier: "modelStore.in-memory")+      .firstMatch+    XCTAssertTrue(marker.waitForExistence(timeout: 5))+    return app+  }++  @MainActor+  private func openTripDetail(_ app: XCUIApplication, tripName: String) {+    let row = app.staticTexts[tripName]+    if row.waitForExistence(timeout: 3), app.buttons["New Trip"].exists {+      row.tap()+    }+  }++  @MainActor+  private func openPackingSheetForFirstParticipant(_ app: XCUIApplication) {+    let summaryRow = app.descendants(matching: .any)+      .matching(NSPredicate(format: "identifier BEGINSWITH 'tripDetail.packingSummary.'"))+      .firstMatch+    if summaryRow.waitForExistence(timeout: 5) {+      summaryRow.tap()+    }+  }++  /// Reveals the inline add field on the visible interactive row, types+  /// `text`, and submits (newline). The field stays open for rapid multi-add,+  /// so callers resign it by tapping the background when done.+  @MainActor+  private func addSubItem(_ app: XCUIApplication, text: String) {+    let addButton = app.descendants(matching: .any)+      .matching(identifier: "packingSubItems.addButton")+      .firstMatch+    XCTAssertTrue(addButton.waitForExistence(timeout: 3))+    addButton.tap()++    let addField = app.textFields["Add sub-item"]+    XCTAssertTrue(addField.waitForExistence(timeout: 3))+    addField.tap()+    addField.typeText(text)+    addField.typeText("\n")+  }++  @MainActor+  private func entry(_ app: XCUIApplication, _ text: String) -> XCUIElement {+    app.descendants(matching: .any)+      .matching(identifier: "packingSubItems.entry.\(text)")+      .firstMatch+  }++  // MARK: - Inline add / remove + read-only display++  @MainActor+  func testAddRemoveAndReadOnlySubItem() {+    let app = launchedApp(fixture: "phase4-pack-mode-trip")+    openTripDetail(app, tripName: "Beach Trip")+    openPackingSheetForFirstParticipant(app)++    let header = app.descendants(matching: .any)+      .matching(identifier: "packingSheet.header")+      .firstMatch+    XCTAssertTrue(header.waitForExistence(timeout: 5))++    // Sunscreen is `.unpacked` for Arjen → an interactive Still-need-to-pack+    // row exposing the inline add affordance.+    let row = app.descendants(matching: .any)+      .matching(identifier: "packingSheet.itemRow.Sunscreen")+      .firstMatch+    XCTAssertTrue(row.waitForExistence(timeout: 5))++    // Add → renders on the row.+    addSubItem(app, text: "Spray bottle")+    XCTAssertTrue(+      entry(app, "Spray bottle").waitForExistence(timeout: 5),+      "Submitting a sub-item should render it on the row"+    )++    // Remove → drops from the row.+    let remove = app.buttons.matching(NSPredicate(format: "label == %@", "Remove Spray bottle"))+      .firstMatch+    XCTAssertTrue(remove.waitForExistence(timeout: 3))+    remove.tap()+    XCTAssertFalse(+      entry(app, "Spray bottle").waitForExistence(timeout: 2),+      "Removing the sub-item should drop it from the row"+    )++    // Re-add, then move the item to the read-only Not bringing group.+    addSubItem(app, text: "Goggles")+    XCTAssertTrue(entry(app, "Goggles").waitForExistence(timeout: 5))++    let skip = app.buttons.matching(NSPredicate(format: "label == %@", "Skip")).firstMatch+    XCTAssertTrue(skip.waitForExistence(timeout: 3))+    skip.tap()+    let restore = app.buttons.matching(NSPredicate(format: "label == %@", "Restore")).firstMatch+    XCTAssertTrue(restore.waitForExistence(timeout: 3))++    assertReadOnlyDisplay(app)+  }++  /// On the read-only Not-bringing row the sub-item still displays but neither+  /// the add affordance nor the per-entry remove control appears (Req 5.2).+  @MainActor+  private func assertReadOnlyDisplay(_ app: XCUIApplication) {+    XCTAssertTrue(+      entry(app, "Goggles").waitForExistence(timeout: 3),+      "A read-only row must still display its sub-items (Req 5.2)"+    )+    XCTAssertFalse(+      app.descendants(matching: .any)+        .matching(identifier: "packingSubItems.addButton")+        .firstMatch.exists,+      "Read-only rows must not present the add affordance (Req 5.2)"+    )+    XCTAssertFalse(+      app.buttons.matching(NSPredicate(format: "label == %@", "Remove Goggles"))+        .firstMatch.exists,+      "Read-only rows must not present a remove control (Req 5.2)"+    )+  }+}
specs/packing-item-subitems/decision_log.md Modified +434 / -0
diff --git a/specs/packing-item-subitems/decision_log.md b/specs/packing-item-subitems/decision_log.mdnew file mode 100644index 0000000..2634b68--- /dev/null+++ b/specs/packing-item-subitems/decision_log.md@@ -0,0 +1,434 @@+# Decision Log: Packing Item Sub-items++## Decision 1: Store both a free-form note and a sub-item list++**Date**: 2026-06-21+**Status**: accepted++### Context++T-1587 asks for per-trip notes and sub-items on packing items so a generic item ("toys", "books") can record exactly which specific things to bring. The content could be modelled as a single free-form note, a structured list of sub-items, or both.++### Decision++Each packing item carries an optional free-form note plus an ordered, appendable list of sub-item entries, both scoped to the trip.++### Rationale++The ticket language ("exactly which ones", "add to while packing") points at a discrete, appendable list. The "notes" half of the ticket title covers detail that isn't a discrete item (e.g. "keep batteries out of the toys"), which a flat list can't express well. Supporting both matches the user's selection and the dual phrasing of the ticket.++### Alternatives Considered++- **Single free-form note only**: Simplest model and sync surface - rejected because it can't render "add to while packing" as discrete, individually removable entries.+- **Sub-item list only**: Structured and appendable - rejected because the note use case ("keep batteries out") has no natural home as a list entry.++### Consequences++**Positive:**+- Covers both the "which ones" and "extra detail" use cases.+- Sub-items are individually removable; the note is a single editable blob.++**Negative:**+- Two pieces of content to model, render, and sync rather than one.++---++## Decision 2: Sub-item status is not tracked++**Date**: 2026-06-21+**Status**: accepted++### Context++The ticket states sub-item status does not need tracking. The app's packing items already carry a `PackingState` (unpacked / packed / repacked / excluded).++### Decision++Sub-items are reference text only. They have no packed/unpacked state and do not participate in counts, progress, or group membership.++### Rationale++Tracking per-sub-item state would multiply the state machine and the packing-sheet UI for no stated benefit. The user packs the parent item ("toys") as a unit; the sub-items just remind them what to put in.++### Consequences++**Positive:**+- No new state machine, no impact on the existing pack/repack counters.++**Negative:**+- A user wanting to tick off individual sub-items is not served (explicit non-goal).++---++## Decision 3: Inline display on the row with quick-add++**Date**: 2026-06-21+**Status**: accepted++### Context++Three surfaces were considered for showing and editing sub-items: inline on the packing row, the existing long-press disclosure (shared with "Why is this here?"), or a dedicated editor sheet.++### Decision++Existing note and sub-items render inline under the item name on its packing row. Adding a sub-item uses an inline quick-add affordance on interactive rows.++### Rationale++"Easy to see them and add to while packing" favours always-visible content over a disclosure or sheet that must be opened first. Inline keeps the information in view during the packing flow.++### Alternatives Considered++- **Long-press disclosure**: Reuses an existing region - rejected because content is hidden until opened, working against "easy to see".+- **Dedicated editor sheet**: More room for editing - rejected as too many taps for the quick "add as you think of it" flow.++### Consequences++**Positive:**+- Content and the add affordance are visible during packing.++**Negative:**+- Adds vertical weight to every interactive row; the add affordance must stay visually light. Density to be validated in design (see open point).++---++## Decision 4: View everywhere, edit only on interactive rows++**Date**: 2026-06-21+**Status**: accepted++### Context++Packing rows appear in interactive groups (Still need to pack, Packed, Still in suitcase, Back in suitcase) and read-only groups (Not bringing, Left behind), across pack and repack modes.++### Decision++Existing note and sub-items are visible on every row in every mode and group. Add and remove affordances appear only on interactive (non-read-only) rows.++### Rationale++The information is useful for reference when repacking or reviewing what was left behind, so it should always be visible. Editing affordances follow the existing read-only convention (`SheetGroup.isReadOnly`), which already suppresses the checkbox, Skip/Restore, and Edit on those groups.++### Consequences++**Positive:**+- Consistent with the established read-only-group behaviour.++**Negative:**+- A user cannot tidy sub-items on an excluded/left-behind item without first restoring it.++---++## Decision 5: Per-entry character limits (sub-item 200, note 500)++**Date**: 2026-06-21+**Status**: accepted++### Context++The existing item name is capped at 200 graphemes (`PackingItemForm.nameLimit`). Notes and sub-items need bounds to keep within the per-blob sync size cap (`kRecordBlobSizeCap`, 256 KB) and to keep rows renderable.++### Decision++A single sub-item entry is limited to 200 characters (matching the item-name cap); the note is limited to 500 characters. No hard cap on the number of sub-items beyond the per-blob size limit.++### Rationale++Reusing the 200 cap for sub-items keeps the input rules consistent with item names. 500 for the note gives room for a sentence or two without approaching the blob cap. Counts are bounded implicitly by the existing blob-size guard.++### Alternatives Considered++- **No per-entry limits**: Simplest - rejected because unbounded text risks the blob-size cap and unbounded row height.++### Consequences++**Positive:**+- Concrete, testable bounds consistent with existing input handling.++**Negative:**+- The 500 note cap is a judgement call and may need adjustment after use.++### Note (amended after review)++The unit is **grapheme clusters**, not "characters" — the existing `PackingItemForm.nameLimit` uses `String.prefix(200)`, which counts grapheme clusters. Requirements 2.4 / 4.4 were corrected to say "grapheme clusters" so the test asserts what the implementation enforces. A separate sub-item **count** cap was added — see Decision 9.++---++## Decision 6: Last-writer-wins sync, consistent with existing fields++**Date**: 2026-06-21+**Status**: accepted++### Context++`TripPackingItem` syncs through `TripSyncEngine` / `TripPackingItemRecordTranslator`. Codable blob fields (e.g. `Trip.attributesData`) resolve concurrent edits per-field as last-writer-wins.++### Decision++Notes and sub-items sync as item fields and resolve concurrent cross-device edits with the same last-writer-wins semantics as the item's other attributes. The sub-item list is treated as one field for conflict purposes.++### Rationale++Matching the established blob/field behaviour avoids inventing merge logic. Concurrent sub-item additions from two devices may lose one side's change; this is acceptable for v1 and consistent with how the app already handles concurrent attribute edits.++### Alternatives Considered++- **Union / append-merge of the sub-item list**: Suggested in review to avoid dropping concurrent additions - rejected. Union without tombstones resurrects deleted sub-items (device A removes "socks", device B's blob still contains it, the merge brings it back), which is a persistent, confusing failure worse than the rare dropped add. Correct union needs per-entry IDs + tombstones + GC (an OR-Set CRDT), breaking the app's uniform "Codable blob, LWW per field" model and the stated simplicity value. Two external reviewers (Codex, Kiro) independently reached the same conclusion.++### Consequences++**Positive:**+- No new conflict-resolution machinery; reuses the proven translator pattern.++**Negative:**+- Simultaneous edits to the **same item's** list on two devices can drop one side's addition. Acceptable for the expected low-contention family packing flow; Req 6.3 now states this consequence explicitly rather than leaving it silent.++### Escalation path (if concurrent-add loss bites)++If real usage shows lost concurrent additions, the non-CRDT escalation is **one CKRecord per sub-item** (child records): appends never conflict, deletes become real tombstones, and it also removes the `ForEach`-identity problem. Rejected for v1 (new record type + ordering + relationship handling; contradicts the uniform-blob model), but it is the cleaner-than-CRDT next step.++---++## Decision 7: Store note and sub-items as two separate fields++**Date**: 2026-06-21+**Status**: accepted++### Context++The note and the sub-item list are logically independent pieces of content. They could share one Codable blob field on `TripPackingItem` or be two separate stored fields. CloudKit/`TripPackingItem` syncs with last-writer-wins at the **field** granularity (Decision 6).++### Decision++Store the note as its own `String?` field and the sub-item list as its own `Data?` field — not a single combined blob.++### Rationale++The note is naturally a scalar string (human-readable on the CKRecord, one translator line) and the sub-items a list blob; folding the note into the sub-items blob would needlessly bury it in JSON. Two fields is the cleaner representation at no cost.++**Correction (post design-phase review):** an earlier draft of this decision justified the split by claiming it gives *cross-field conflict independence* (a note-only edit wouldn't clobber a concurrent sub-item add). **That is false in this codebase.** `TripSyncEngine` has no field-level dirty tracking — every save rebuilds the entire `CKRecord` via `toRecord` and pushes all fields (verified in `TripSyncEngine.swift`). The whole record is one LWW conflict domain regardless of field count, so a note-only edit re-sends a then-current `subItemsData` and can overwrite a concurrent remote add. The two-field split is kept for representational cleanliness only; it provides **no** conflict-isolation benefit. Genuine note/sub-item independence would require separate *records* (child records), rejected for v1 (see Decision 6 escalation note).++### Alternatives Considered++- **Combined Codable struct (`{note, subItems}`) in one field**: Slightly fewer stored properties - rejected for burying the note in a blob; no conflict-domain difference either way (whole-record LWW).+- **Separate child records per sub-item**: would give real append-without-conflict + real tombstones - rejected for v1 (new record type, ordering, relationship handling; contradicts the uniform-blob model). Recorded as the escalation path.++### Consequences++**Positive:**+- Note is a readable scalar field; clean translator code.++**Negative:**+- No cross-field conflict isolation (whole-record LWW) — do not assume otherwise.+- Two stored properties + two translator fields instead of one.++---++## Decision 8: Explicit sub-item count cap of 50++**Date**: 2026-06-21+**Status**: accepted++### Context++Decision 5 set per-entry length caps but declined a cap on the *number* of sub-items, deferring to the 256 KB per-blob sync cap (`kRecordBlobSizeCap`). Review showed that cap is enforced only on the encode/send path — and `TripPackingItemRecordTranslator` has no cap check at all today — so an over-large list saves locally and only fails later, asynchronously, at sync upload, far from the inline editor. ~256 KB also allows ~1,300 entries, giving an unbounded row height.++### Decision++Cap a packing item at 50 sub-items. Adding past the cap is prevented inline at the point of entry.++### Rationale++A small, explicit cap makes the limit testable, bounds row height by design, and keeps the blob at tens of KB — far under 256 KB, which makes the blob-overflow path effectively unreachable and defuses the AC 6.4 failure-surfacing problem. 50 is a judgement call (well above any realistic "which toys" list), not a load-bearing constant.++### Alternatives Considered++- **No count cap (rely on blob size)**: Untestable ("the right number of entries"), produces a cliff-edge sync failure mid-packing, and leaves row height unbounded - rejected.++### Consequences++**Positive:**+- Predictable "list full" state instead of a late sync failure; bounded row height; one change defuses several findings.++**Negative:**+- 50 is arbitrary and may need tuning (cheap to change).++---++## Decision 9: Pre-save inline validation, not a sync-time failure alert++**Date**: 2026-06-21+**Status**: accepted++### Context++The original AC 6.4 said the system should "surface a save failure" when content exceeds the blob cap. But the packing-sheet state-mutation save path (`PackingSheet.save(_:)`) logs-and-swallows errors with no alert (`packing-sheet.md`: "No alert / banner in v1"), and the blob cap is only checked at sync-upload time. Honouring 6.4 literally would need new sync-time UI that contradicts the established pattern.++### Decision++Enforce length and count limits as a client-side guard **before** save (reject the input inline at the point of entry, like `PackingItemForm` does for the item name). Do not add a sync-time failure alert.++### Rationale++Validating at entry is consistent with the existing form's inline-error pattern, keeps the swallow-on-save behaviour of the packing sheet intact, and — combined with the count cap (Decision 8) — means the blob cap is never reached in practice. AC 6.4 was rewritten accordingly.++### Consequences++**Positive:**+- Consistent with existing input handling; no new sync-error UI; the over-limit path is caught where the user is typing.++**Negative:**+- The guard logic must live at every entry point (inline quick-add and, if used, the form).++---++## Decision 10: Note-clear must propagate as a clear across devices++**Date**: 2026-06-21+**Status**: accepted++### Context++`TripPackingItemRecordTranslator.from(_:)` follows the convention "absent key on the inbound record = no change" (it only assigns when the key resolves). For a brand-new field, *clearing* a note to nil/empty could be read by other devices as "no change" and lose to a stale non-empty value under LWW — a silent, confusing data issue. Surfaced as a new gap by external review.++### Decision++Clearing a note or sub-item list on one device propagates the cleared (empty) value to other devices, rather than being indistinguishable from "field unchanged". Round-trip tests cover nil / empty / non-empty.++### Rationale++A user who deletes a note expects it gone everywhere. The encode/decode contract for the new fields must distinguish "cleared" from "absent" so a clear survives sync. This is the same class of deliberate handling already applied to `countryCode` and `personSnapshotID`.++### Consequences++**Positive:**+- Deletes behave predictably across devices.++**Negative:**+- The translator needs explicit empty-vs-absent handling for the two new fields, with tests — slightly more than the default `if let` pattern.++---++## Decision 11: Sub-items as a Codable `Data?` blob, not a native list field++**Date**: 2026-06-21+**Status**: accepted++### Context++Sub-items (`[String]`) could be stored as a native SwiftData `[String]` (encoded as a CKRecord string-list field by the translator) or as a JSON-encoded `Data?` blob via the existing `CodableBridge`, like `Trip.attributesData` and `MasterPackingItem.conditionsData`.++### Decision++Store sub-items as `subItemsData: Data?`, exposed through a `subItems: [String]` `CodableBridge` extension. The note is a plain `String?`.++### Rationale++Matches the established blob-field convention, reuses `CodableBridge` and the `kRecordBlobSizeCap` check, and keeps one uniform field-encoding path in `TripPackingItemRecordTranslator`. The 50-item × 200-char caps bound the blob to ~10 KB, far under the 256 KB cap.++### Alternatives Considered++- **Native `[String]` / CKRecord string-list**: Slightly less code (no bridge) - rejected for diverging from the codebase's uniform blob pattern and its size-cap handling.++### Consequences++**Positive:** uniform with existing blob fields; one encoding path. **Negative:** a JSON encode/decode per read/write of the list (negligible at these sizes).++---++## Decision 12: Unconditional translator assignment (clear-propagation), per the `countryCode` precedent++**Date**: 2026-06-21+**Status**: accepted++### Context++Decision 10 requires note/sub-item clears to propagate across devices. `TripPackingItemRecordTranslator.from` currently uses conservative `if let` assignment for `personSnapshotID` ("absent = no change") to avoid clobbering from older clients, but `TripRecordTranslator` uses **unconditional** assignment for `countryCode` ("full snapshot ⇒ absent = cleared").++### Decision++Decode `note` and `subItemsData` with unconditional assignment (`item.note = record["note"] as? String`) so a cleared value reaches other devices. This is a deliberate divergence from the sibling fields `personSnapshotID`/`tripID` in the same translator, which use conservative `if let` ("absent = no change").++### Rationale++`CKSyncEngine` delivers full record snapshots, so an absent field genuinely means "cleared". Unconditional assignment is the only way to satisfy Req 6.5. The **in-file precedent** is `masterItemID`, already decoded unconditionally (nil-clears) in this same translator; `countryCode` in `TripRecordTranslator` is the cross-file precedent. `personSnapshotID`/`tripID` chose the conservative model to tolerate a dangling/omitted reference, which doesn't apply to user content that must clear. The trade-off — an older client that doesn't write these fields would clear them on its writes — is acceptable for a single-developer app whose devices update together, the same trade-off Phase 6 accepted for `countryCode`.++### Consequences++**Positive:** deletes propagate correctly. **Negative:** mixed-client-version coexistence on one shared trip could clear the fields; out of scope for v1.++---++## Decision 13: Note edited in `PackingItemForm`; sub-items added inline (resolves open points 1 & 2)++**Date**: 2026-06-21+**Status**: superseded by Decision 14++### Context++Open points from the requirements phase: where the note is edited, and how to avoid the inline add affordance cluttering long packing lists.++### Decision++The **note** is edited in the existing `PackingItemForm` (add a note field), reusing its rollback-on-save-failure path. **Sub-items** use an inline reveal-on-tap quick-add on the row: a compact "+ add item" control that reveals a focused `TextField` on tap, appends on submit, stays open for rapid multi-add. Both note and sub-items display inline on the row (read).++### Rationale++Multiline note editing belongs in the form (which already handles name + save-failure UX); a sheet-on-sheet for sub-items would be too many taps for "add as you think of it". Reveal-on-tap keeps empty rows visually light (just a small control, no persistent field), addressing the density risk without losing one-tap add.++### Consequences++**Positive:** empty rows stay light; note gets a proper error path; sub-item add is one tap. **Negative:** the note is one extra step (open Edit) rather than inline.++### Note discoverability (post-review refinement)++The design-critic flagged that form-only note editing has no inline signal and no path on read-only rows. Resolution: an **existing** note renders inline and is tappable to open the edit form; the form now carries a labelled Note field, so the path is self-revealing once Edit is opened. A dedicated inline "add note" control for the empty case was rejected — a second per-row affordance fights the density this design protects. The rationale is therefore a deliberate density trade-off, not merely "notes are less frequent." Read-only rows display the note without an edit path, consistent with sub-items (Decision 4).++---++## Decision 14: Trailing note/list glyphs with inline note editing (supersedes Decision 13)++**Date**: 2026-06-24+**Status**: accepted++### Context++On-device review of the as-built UI (Decision 13) surfaced two problems. The persistent "+ add item" reveal-on-tap control rendered as a full-width row under *every* interactive item, pushing each one onto two lines and visually cluttering the packing sheet — the exact density the design tried to protect. And editing a note only through `PackingItemForm` was an extra modal step with no per-row affordance for the empty case (the open point Decision 13 left as a deliberate trade-off).++### Decision++Replace the persistent affordance row with two compact glyphs in each row's trailing controls, just left of Skip: a **note glyph** (`note.text`, tinted person-colour when a note exists, secondary when empty) and a **sub-item list glyph** (`list.bullet`). Each glyph reveals its own inline editor on demand and the two are mutually exclusive. The **note is now edited inline** via the note glyph (or by tapping the displayed note text) through a new `PackingItemGroup.saveNote` that commits `PackingSubItems.sanitizedNote(_:)` through the same `LocalWriteHook` chokepoint as the sub-item mutations; the inline note field saves on blur with a live 500-cap (`PackingSubItems.cappedNote`). `PackingItemForm` keeps its note field, so the form remains a full edit path — but the glyph is now the primary path.++### Rationale++Glyphs keep a sub-item-less item a single line (the affordance only costs trailing icon space, not a row), restoring sheet density. Inline note editing removes the open-the-form step and gives the empty-note case a discoverable, person-colour-cued affordance directly on the row, which is strictly better than the Decision 13 trade-off. Reusing the existing `LocalWriteHook` save chokepoint keeps the new note path consistent with the sub-item add/remove writes (no new persistence surface).++### Alternatives Considered++- **Keep Decision 13's reveal-on-tap row**: Rejected — device review showed it doubled every interactive row's height.+- **Alert / popover for sub-item and note input**: Rejected for now — heavier than an inline field for "add as you think of it"; kept as a fallback if the inline field proves cramped under the keyboard.+- **A single combined "content" glyph opening a sub-sheet**: Rejected — a sheet-on-sheet is the tap-count cost Decision 13 already ruled out.++### Consequences++**Positive:**+- Sub-item-less items stay a single line; the sheet reads cleanly again.+- Note add/edit is one tap on the row, with a has-note colour cue, and works without the form.+- The note write reuses the existing hook chokepoint (one save path for all per-trip content edits).++**Negative:**+- Three trailing controls (note, list, Skip) plus the checkbox can be tight on narrow rows / large Dynamic Type — to watch.+- The inline editors still render *beneath* the row while active; keyboard scroll-into-view on reveal (design § "Focus / keyboard / dismissal") is **not yet wired** and is carried as an open item.+- VoiceOver labels / UI-test identifiers for the new glyph placement, and tests for `PackingItemGroup.saveNote` + the inline note editor, are outstanding (the test harness can't run under Xcode 26.5 — see `docs/agent-notes/testing.md`).++### Impact++`PackingItemRow` (trailing glyphs, parent-owned `isAddingSubItem` / `isEditingNote` state, single combined visibility report to the sheet), `PackingSubItemsView` (inline note editor alongside the sub-item field, parent-controlled bindings), `PackingSheet`/`PackingItemGroup` (`saveNote`), and `PackingSubItems` (`cappedNote`).++---++## Carried into implementation / still open++- **Edit-during-sync race**: behaviour when a remote record re-materialises the `@Model` mid sub-item add (local unsaved text vs incoming sync on the same device). Non-goal for guaranteed merge; the reveal-on-tap field's local `@State` text is independent of the model until submit, which bounds the exposure.+- **Migration constraint**: both new properties are Optional on the existing shared `TripPackingItem`, riding on `SchemaV3` — **no `SchemaV4`** (avoids the "Duplicate version checksums" crash; see `persistence.md`).+- **CKShare participant permissions**: v1 has no read-only participant tier ("interactive" means the `SheetGroup.isReadOnly`, not share permission), so no mapping is needed.
specs/packing-item-subitems/design.md Modified +226 / -0
diff --git a/specs/packing-item-subitems/design.md b/specs/packing-item-subitems/design.mdnew file mode 100644index 0000000..e871058--- /dev/null+++ b/specs/packing-item-subitems/design.md@@ -0,0 +1,226 @@+# Design: Packing Item Sub-items++> **Implementation note (2026-06-24): UI reworked post-design.** The note/sub-item+> *interaction* below (the "+ add item" reveal-on-tap row and editing the note+> through `PackingItemForm`) was superseded after on-device review by trailing+> **note (`note.text`) and list (`list.bullet`) glyphs** with **inline note+> editing** via `PackingItemGroup.saveNote`. See **Decision 14** in+> `decision_log.md` for what changed and why. The data model, sync/translator,+> migration, and `PackingSubItems` helper sections below are unchanged and+> remain accurate.++## Overview++Add an optional per-trip free-form note and an appendable sub-item list to `TripPackingItem`, displayed inline on the packing-sheet row and edited while packing. Two new Optional properties ride on `SchemaV3`; sync flows through the existing `TripPackingItemRecordTranslator`.++## Architecture++### What changes++| Layer | File | Change |+|---|---|---|+| Model | `Models/TripPackingItem.swift` | Add `note: String?`, `subItemsData: Data?`, and a `subItems: [String]` Codable bridge. |+| Domain | `Models/PackingSubItems.swift` (new) | Pure, testable validation/mutation helpers + caps. |+| Sync | `Sharing/Translators/TripPackingItemRecordTranslator.swift` | Encode/decode `note` + `subItemsData`, following the `countryCode` clear-propagation precedent. |+| UI (display+edit) | `Components/PackingSubItemsView.swift` (new) | Renders the note + sub-item list + inline quick-add/remove. |+| UI (host) | `Components/PackingItemRow.swift` | Embed `PackingSubItemsView`; thread add/remove closures + accessibility actions. |+| UI (mutations) | `Features/Trips/PackingSheet.swift` (`PackingItemGroup`) | `addSubItem` / `removeSubItem` methods that mutate and `save(_:)` through the existing `hook.commit` chokepoint. |+| UI (note edit) | `Features/Trips/PackingItemForm.swift` | Add a note field to the existing add/edit form. |+| Migration | `Persistence/Migrations/ZoneMigrationCoordinator.swift` | The `relocateTrip` clone (≈line 360) must copy `note` + `subItemsData` onto the relocated item. |++No new schema version. No changes to the rules engine, counts, groups, or `PackingListHelpers` (Req 7.3).++### Integration points++- **Display**: `PackingSubItemsView` mounts inside `PackingItemRow`'s existing name/disclosure `VStack`, below the name and above the `WhyDisclosureView`. It renders in every group (Req 5.1); the `SheetGroup.isReadOnly` flag (defined on the `SheetGroup` enum in `PackingItemRow.swift`) gates the add/remove affordances (Req 5.2). `PackingItemRow.body` MUST read `item.note` and `item.subItems` directly (not only inside the child) so SwiftData establishes observation on `note`/`subItemsData` — that is what makes an inbound sync re-render the row. `PackingSubItemsView` itself is purely presentational (plain-value params, no `@Model` reference); its only refresh trigger is the parent re-rendering.+- **Sub-item writes** funnel through `PackingItemGroup.save(_:)` — the same `hook.commit(modelContext)` path the checkbox/skip mutations already use. No new `tripsLocal` save site.+- **Note writes** funnel through `PackingItemForm.performAdd/performEdit`, which already commit via `hook.commit` with rollback-on-failure.+- **Sync**: only the two translator methods change; `LocalWriteHook` already marks the item dirty on any field change, so no dirty-marking changes.++### Pattern parity audit++`TripPackingItem` field handling appears in exactly these places; each new field must be handled in the same set:++| Site | Needs note + subItems? | Notes |+|---|---|---|+| `TripPackingItem.init` | yes | New optional params, defaulting nil. |+| `TripPackingItemRecordTranslator.toRecord` | yes | Encode both; blob-cap check on `subItemsData`. |+| `TripPackingItemRecordTranslator.from` | yes | Unconditional assignment (clear-propagation). |+| `PackingItemForm.performAdd` | note only | New items can carry a note; sub-items start empty. |+| Rules engine `Apply.insertAddedPacking` (≈line 71) | no | Fresh-create from a newly-matched master — no prior per-trip item to carry fields from. |+| Rules engine `Apply.flagPacking` (≈line 134) | no | Mutates `currentlyMatchesRules` only on existing instances; never recreates (verified). |+| `PackingListHelpers` (counts/sort, PR #10) | no | Pure read/query helper; never constructs an item (Req 7.3). |+| `SchemaV3MigrationStage` | no | Mutates `personSnapshotID` in place; never recreates. |+| **`ZoneMigrationCoordinator.relocateTrip` (≈line 360)** | **yes — clone** | Builds a new `TripPackingItem` from an existing one (copies id/name/state/source/flags/`personSnapshot`/`ckRecordSystemFields`). MUST also copy `note` + `subItemsData`, or the V2→V3 zone relocation silently drops them. |+| `MasterPersistence.copyPacking` (PR #9) | no | Constructs `MasterPackingItem`, not `TripPackingItem`. |+| `SnapshotMaintenance` / `TripDeletion` | no | Fields live on the item; deletion cascades with the item (Req 7.4). |++## Data Models++Two Optional stored properties on the existing shared `TripPackingItem` (MUST be Optional with `nil` default so they ride on `SchemaV3` via lightweight inference — a property-only addition to a shared top-level class cannot be a new `SchemaV4`; see `persistence.md` "Shared top-level classes can't express property-only schema bumps").++```swift+// stored+var note: String?            // nil or empty ⇒ no note+var subItemsData: Data?      // nil/empty ⇒ no sub-items; JSON-encoded [String] otherwise++// bridge (extension, never a stored relationship)+var subItems: [String] {+  get {+    guard let data = subItemsData, !data.isEmpty else { return [] }   // empty Data() ⇒ []+    return CodableBridge.decode(data, as: [String].self, default: [], label: "TripPackingItem.subItems")+  }+  set { subItemsData = newValue.isEmpty ? nil : CodableBridge.encode(newValue, label: "TripPackingItem.subItems") }+}+```++**Invariant**: empty list ⇒ no sub-items, treated identically whether `subItemsData` is `nil` or a non-nil empty `Data()`. The getter normalises empty `Data()` to `[]` because `CodableBridge.encode` returns empty `Data()` (never nil) on its degrade path — so the "empty ⇒ nil" setter guarantee is reinforced on read. The translator's clear check (below) tests `data.isEmpty`, not just nil, for the same reason. Order is array order (Req 1.3).++Why a `Data?` blob rather than a native `[String]` SwiftData/CKRecord list: it adopts the `CodableBridge` blob pattern used on `Trip.attributesData` / `MasterPackingItem.conditionsData` and the `kRecordBlobSizeCap` guard from `TripRecordTranslator`. Note this is the **first** Codable blob on `TripPackingItem` and its translator — the pattern is imported here, not pre-existing on this model — so the cap check is net-new code, not a reused call.++## Components and Interfaces++### `PackingSubItems` (pure helpers)++All validation/mutation logic lives here so it is unit-testable without a view or a `ModelContext`.++```swift+enum PackingSubItems {+  static let maxCount = 50            // Req 2.7+  static let maxItemLength = 200      // Req 2.4 (grapheme clusters)+  static let maxNoteLength = 500      // Req 4.4 (grapheme clusters)++  enum AddOutcome: Equatable { case added([String]), rejectedEmpty, rejectedFull }++  static func sanitizedEntry(_ raw: String) -> String          // trim, cap to maxItemLength graphemes+  static func appending(_ raw: String, to list: [String]) -> AddOutcome+  static func removing(at index: Int, from list: [String]) -> [String]   // bounds-checked no-op if OOR+  static func sanitizedNote(_ raw: String) -> String?          // trim, cap maxNoteLength; nil if empty+}+```++- `appending`: trims+caps; `rejectedEmpty` if blank; `rejectedFull` if already at `maxCount`; otherwise `added(list + [entry])`. Duplicates are kept (Req 2.6) — no de-dup.+- `removing`: by **index**, not value, because duplicates are allowed and removal must target a position.++### `PackingSubItemsView` (new)++```swift+struct PackingSubItemsView: View {+  let note: String?+  let subItems: [String]+  let isInteractive: Bool          // == !SheetGroup.isReadOnly+  let accent: Color                // person colour+  let onAdd: (String) -> Void      // → PackingItemGroup.addSubItem+  let onRemove: (Int) -> Void      // → PackingItemGroup.removeSubItem (by list index)+  let onEditNote: () -> Void       // opens PackingItemForm in edit mode+}+```++Renders, top to bottom: the note, the sub-item rows, then — `isInteractive` only — a compact add affordance. Empty + non-interactive ⇒ renders nothing (Req 5.3: row unchanged).++**List identity (resolves the `ForEach`-over-`[String]` hazard).** A flat `[String]` with duplicates (Req 2.6) has no natural stable id, and `id: \.self` would collapse/mis-target duplicate rows. The view holds a row-local mirror `@State private var drafts: [SubItemDraft]` where `struct SubItemDraft: Identifiable { let id = UUID(); let text: String }`, seeded from `subItems` and re-seeded on `.onChange(of: subItems)` (e.g. an inbound sync). `ForEach` iterates `drafts` by `id`; a remove maps the draft's current position → index and calls `onRemove(index)`. The stored model stays `[String]` — no persisted ids, no schema impact. There is no inline rename, so re-seeding on sync never discards an in-progress edit of an existing entry.++**Density (open point 1).** The add affordance is a compact "+ add item" control, not a persistent field. Tapping it reveals an inline `TextField` bound to a row-local `@FocusState`; it appends on submit and stays open for rapid multi-add. The 200-grapheme cap is enforced live via `.onChange` (mirrors `PackingItemForm.cappedName`). Hidden when `subItems.count == maxCount` (Req 2.7). Empty rows show only the light "+ add item" control. Visual: person-colour accent referencing the sheet-level `DashedAddButton` (a styling cue, not a component being extended). Each sub-item row's remove control matches the existing inline `Skip`/`Restore` button styling and is ≥44×44 (Req 8.3).++**Focus / keyboard / dismissal.** On reveal, the row scrolls above the keyboard via the sheet's existing `ScrollViewReader` proxy (`proxy.scrollTo(row, anchor:)`). Opening the add field first closes any open `WhyDisclosure` for that row — one inline expansion at a time. Outside-tap dismissal: the sheet's existing background tap-catcher (today gated on `openDisclosureItemID != nil`) is widened to also clear the active add-field focus. Empty submit or blur dismisses the field.++**Note display + edit (resolves open point 2 / the discoverability concern).** The note renders as secondary text, visually distinct from the list. When present and the row is interactive, the note text is tappable and calls `onEditNote` (opens `PackingItemForm`, which now carries the Note field). When absent, there is **no** dedicated inline "add note" control — a second per-row affordance would worsen the density this design protects; notes are reached via the row's existing Edit affordance, whose form now shows a labelled Note field, making the path self-revealing once Edit is opened for any reason. On read-only rows the note displays but is not tappable (no edit), consistent with sub-items.++**Accessibility tree.** `PackingItemRow` currently uses `.accessibilityElement(children: .combine)`, which flattens the row into one element — incompatible with per-sub-item addressable elements (Req 8.2). Restructure: the name + state + checkbox stay one combined "row" element carrying the row-level **Add sub-item** custom action (Req 8.2) and the note in its label (Req 8.1); the sub-item list becomes a sibling container (`.accessibilityElement(children: .contain)`) whose entries are individually focusable, each exposing a **Remove** action. Dynamic Type: each sub-item wraps (no truncation, per `accessibility.md`'s row convention); the row's `minHeight: 44` and top-aligned checkbox hold as the VStack grows. A 50-entry row at AX sizes is an accepted rare worst case (Decision 8 bounds it).++### `PackingItemGroup` mutations (in `PackingSheet.swift`)++```swift+private func addSubItem(_ item: TripPackingItem, _ raw: String) {+  switch PackingSubItems.appending(raw, to: item.subItems) {+  case .added(let list): item.subItems = list; save("addSubItem")+  case .rejectedEmpty, .rejectedFull: return       // pre-save guard; no write+  }+}+private func removeSubItem(_ item: TripPackingItem, at index: Int) {+  item.subItems = PackingSubItems.removing(at: index, from: item.subItems)+  save("removeSubItem")+}+```++Mirrors the existing `toggleState`/`skipOrRestore` shape. `save(_:)` keeps the existing log-and-swallow behaviour for transient commit failures (Decision 9: validation is pre-save; only the cap/empty guards reject, and they reject *before* any write).++### `PackingItemForm` note field++Add an optional note `TextField(axis: .vertical)` below the name field, capped via `PackingSubItems.sanitizedNote` semantics (live cap to 500 graphemes). `performAdd`/`performEdit` set `item.note = PackingSubItems.sanitizedNote(note)`. Clearing the field to empty sets `nil` (Req 4.3). Shown in both add and edit modes.++### Translator changes++```swift+// toRecord+record["note"] = item.note as CKRecordValue?          // nil clears (masterItemID precedent)+if let data = item.subItemsData, !data.isEmpty {      // empty Data() also clears+  guard data.count <= kRecordBlobSizeCap else {+    throw TranslatorError.blobTooLarge(field: "subItemsData", size: data.count)+  }+  record["subItemsData"] = data as CKRecordValue+} else {+  record["subItemsData"] = nil                        // clear+}++// from  — unconditional, so a clear on the wire reaches this device (Req 6.5)+// Contract: requires a FULL server snapshot. Never pass a partial /+// desiredKeys record here — an absent data field is read as "cleared",+// so a partial record would wipe local note/subItems. CKSyncEngine's+// fetch path delivers full records; this holds today (verified) and the+// `from` doc comment must state it so no future desiredKeys path regresses it.+item.note = record["note"] as? String+item.subItemsData = record["subItemsData"] as? Data+```++**Convention note**: this is a *deliberate divergence* from the sibling fields in the same translator. `personSnapshotID` and `tripID` use conservative `if let` ("absent = no change, can't distinguish omitted from cleared"); the new fields instead use **unconditional** assignment so a clear propagates (Req 6.5). The in-file precedent for unconditional/nil-clears is `masterItemID` (already decoded this way); `countryCode` in `TripRecordTranslator` is the cross-file precedent. The rationale for choosing clear-propagation over the conservative model: full-snapshot `CKSyncEngine` delivery means absent genuinely == cleared, and a user deleting a note expects it gone everywhere. Trade-off: an older client that omits these fields would clear them on its writes — acceptable (single-developer app, devices update together; same trade-off Phase 6 took for `countryCode`). The count + length caps keep `subItemsData` far under `kRecordBlobSizeCap`, so `blobTooLarge` is effectively unreachable but retained as defence.++## Error Handling++| Condition | Handling | Req |+|---|---|---|+| Empty / whitespace sub-item submitted | `appending` returns `rejectedEmpty`; no write, field stays open | 2.3 |+| 51st sub-item | add control hidden at 50; `appending` returns `rejectedFull` as a backstop | 2.7 |+| Over-length entry/note | capped live at input (`.onChange`); no explicit user feedback, matching the existing silent `cappedName` name-field behaviour | 2.4, 4.4, 6.4 |+| Transient `hook.commit` failure (sub-item) | logged, swallowed; SwiftData re-emits prior state on next body eval (existing packing-sheet pattern) | — |+| Transient save failure (note, via form) | form rollback + inline error (existing `PackingItemForm` pattern) | — |+| `subItemsData` exceeds 256 KB | `blobTooLarge` thrown at encode (unreachable given caps) | 6.4 |++## Concurrency and resolved open points++- **Edit-during-sync race**: the inline add field holds its in-progress text in row-local `@State`, independent of the `@Model` until submit. If a remote sync re-emits the item mid-typing, the visible `subItems` list re-renders but the unsaved field text is untouched; on submit the entry is appended to the *current* list. Resolution: incoming sync never discards typed-but-unsubmitted text, and the last submitted entry wins. No locking, no merge.+- **Whole-record LWW (not just whole-list)**: `TripSyncEngine` has no field-level dirty tracking — every save rebuilds the entire `CKRecord` via `toRecord` and pushes it (verified in `TripSyncEngine.swift`). So `note`, `subItemsData`, and every other field on `TripPackingItem` share **one** LWW conflict domain (the record). A note-only edit re-sends a then-current `subItemsData`; if a concurrent remote sub-item add hasn't been fetched yet, that add is overwritten. This is the accepted Decision 6 trade-off, now stated at record granularity.+- **Rapid multi-add framing (corrected)**: each submit issues one whole-record push. For a *single* writer this narrows each individual conflict window; for *concurrent* writers on the same item it multiplies overwrite events during the shared typing window. Per-entry save is kept for durability (an entry survives an app kill), not sold as a conflict mitigation. Batching on field-dismiss was considered and rejected (loses entries on kill).+- **CKShare permissions**: confirmed out of scope — v1 has no read-only participant tier. "Interactive" means `SheetGroup.isReadOnly`, never share permission, so no participant-permission mapping is needed.+- **CloudKit schema promotion**: the two new record fields appear automatically in the Development environment but must be promoted to Production before release — handled by the existing release-prep CloudKit-schema-promotion checklist (CLAUDE.md / Phase 5 release prep). Flagged here so the fields ship deployed.++## Testing Strategy++**Model bridge (`ScrambleTests`, Swift Testing, `@MainActor`)**+- `subItems` get/set round-trip; `subItems = []` ⇒ `subItemsData == nil`; a non-nil empty `Data()` reads back as `[]` (the `CodableBridge.encode` degrade edge); garbage `Data` decodes to `[]`; `note` set/clear.++**Pure helpers (`PackingSubItemsTests`)**+- `sanitizedEntry` trims and caps at 200 grapheme clusters (multi-scalar emoji survive intact at the boundary).+- `appending`: rejects empty; rejects at 50; appends otherwise; preserves duplicates and order.+- `removing`: removes only the indexed entry, preserves order, OOR index is a no-op.+- `sanitizedNote`: trims, caps 500 graphemes, nil on empty.++**Translator (`TripPackingItemRecordTranslatorTests`)** — extend existing suite+- Round-trip: note + sub-items survive `toRecord → from` without loss or reorder (Req 6.2).+- Clear matrix (gap #1): for note and sub-items, each of {nil, empty, non-empty} → encode → decode reproduces the cleared/empty/populated state; a populated→cleared sequence yields `nil`/`[]` on the receiver (Req 6.5). Include the non-nil empty `Data()` case (must serialise as an absent field, not a present empty blob).+- `blobTooLarge` thrown when `subItemsData` is forced over the cap.++**Lifecycle (`ScrambleTests`)**+- Skip→restore: toggling `state` to `.excluded` and back leaves `note`/`subItems` unchanged (Req 7.5).+- Zone relocation: `ZoneMigrationCoordinator.relocateTrip` carries `note`/`subItemsData` onto the relocated item, and an item with both `nil` relocates as still-`nil` (not an empty blob) — regression guard for the clone site found in the parity audit.+- Migration materialization: opening a store seeded at pre-feature `SchemaV3` shape surfaces the two new columns as `nil` (lightweight inference), confirming no `SchemaV4` and no duplicate-checksum crash.++**Property-based (parameterised, matching the repo's existing `@Test(arguments:)` PBT style)**+- Round-trip identity: for generated `[String]` (incl. duplicates, empty, unicode, boundary-length entries within caps), `decode(encode(xs)) == xs`. Justified: the bridge is a serializer with a universal round-trip guarantee.+- `appending` invariants over a generated sequence of adds/removes: count never exceeds 50; a successful add increases length by exactly 1; `removing` decreases by exactly 1.++**UI test (`ScrambleUITests`)** — one path+- In pack mode: reveal inline add, type a sub-item, submit, assert it renders on the row; remove it; switch the item to Not bringing and assert the sub-item shows but no add/remove control appears.++**Accessibility**+- Each sub-item is its own accessibility element labelled "sub-item: {text}"; interactive rows expose per-element "Remove" actions and a row-level "Add sub-item" custom action (Req 8.1, 8.2).
docs/agent-notes/packing-sheet.md Modified +8 / -0
diff --git a/docs/agent-notes/packing-sheet.md b/docs/agent-notes/packing-sheet.mdindex 1e32786..801b023 100644--- a/docs/agent-notes/packing-sheet.md+++ b/docs/agent-notes/packing-sheet.md@@ -66,3 +66,11 @@ Focus restoration on auto-dismiss runs from `TripDetailView.handlePackingSheetDi ## Dimmed items count in progress (Decision 5)  A dimmed (`!currentlyMatchesRules && !pinnedByUser`) item that is `packed` is **physically in the suitcase** and still counts toward `packed / (unpacked + packed)`. This diverges from Phase 3's task counts (which exclude dimmed via `+N inactive` suffix) on purpose: tasks are required-vs-not state; packing is physical state. See `PackingListHelpers.counts(for:in:)` — no filtering on `currentlyMatchesRules` / `pinnedByUser`.++## Per-trip note + sub-items inline editors (T-1587, Decision 14)++`PackingItemRow` hosts **two mutually-exclusive inline editors** — a note editor and a sub-item add field — revealed by trailing **note (`note.text`) / list (`list.bullet`) glyphs** left of Skip. Gotchas:++- The reveal state (`isAddingSubItem` / `isEditingNote`) is **owned by `PackingItemRow`** and passed as bindings into `PackingSubItemsView`; the row reports "any editor open" to `PackingSheet` from a **single** `onChange(of: isAddingSubItem || isEditingNote)` (not per-binding) so a direct switch between the two editors never momentarily reads as closed and drops the sheet's background dismiss tap-catcher (`activeAddFieldItemID`).+- `PackingItemRow.body` must read `item.note` / `item.subItems` **directly** (hoisted into locals, then threaded into `rowContent`) so SwiftData establishes observation — that is what makes an inbound sync re-render the row. `item.subItems` decodes its JSON blob on each access, so read it once.+- Inline note editing commits through `PackingItemGroup.saveNote` → the same `hook.commit` chokepoint as sub-item add/remove; it saves on blur. The edit form (`PackingItemForm`) still has a note field as the full-edit path.
docs/agent-notes/testing.md Added +66 / -0
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdnew file mode 100644index 0000000..5a8f619--- /dev/null+++ b/docs/agent-notes/testing.md@@ -0,0 +1,66 @@+# Testing notes++## Xcode 26.5 — SwiftData multi-container crash in the test suite++**Symptom.** Running `make test-quick` (or `make test`, or any `xcodebuild test`+covering more than a handful of SwiftData suites) crashes mid-run with a cascade+of `0.000s` failures attributed to "Test crashed with signal trap" /+`Crash: Scramble at <SomeSwiftDataTest>`. ~80–100 tests pass first, then the+host process dies and every remaining test in that process reports as crashed;+xcodebuild retries on a fresh simulator clone and crashes again. The crash is an+`EXC_BREAKPOINT` (`SIGTRAP`) raised **inside SwiftData**, not an assertion+failure.++**Root cause.** Every SwiftData test suite builds its own in-memory container+with a freshly-derived schema:++```swift+let schema = Schema(versionedSchema: SchemaV3.self)   // re-derived per container+let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)+return try ModelContainer(for: schema, configurations: [config])+```++Under the Xcode 26.5 toolchain, creating a **2nd+ `ModelContainer` for the same+`SchemaV3`** within a single test process traps. It is **not** caused by any+feature code — proven by:+- a pre-existing, untouched suite (`TripPackingItemBridgeTests`) crashing 0/N+  when run alone, and+- the same crash reproducing at the pre-feature baseline commit.++It surfaced with the Xcode 26.5 upgrade (June 2026); earlier toolchains+tolerated the repeated-container pattern.++**What still works.**+- `make build` — fine.+- `xcodebuild build-for-testing` — fine (both unit + UI targets compile).+- `make lint` / `make format` — fine.+- A **single** test in its own process (`-only-testing:Suite/func`) sometimes+  runs and reports; often the runner instead reports `0 tests` with scrambled+  output (`"Testing started"` printed after `** TEST SUCCEEDED **`). Either way+  it does not crash — but you cannot rely on it for a clean red/green signal.+- Reusing one shared `Schema` instance across containers avoided the crash in a+  minimal 8-container diagnostic, but **did not** fix a real multi-test suite —+  so schema-sharing is a clue, not the whole fix.++**Recovery that does NOT help:** `simctl shutdown all`, deleting+`~/Library/Developer/XCTestDevices` clones, restarting+`com.apple.CoreSimulator.CoreSimulatorService`, `simctl erase all`, device+reboot. A separate, worse "wedge" (stuck `launchd_sim` surviving `kill -9`) is+cleared by a **machine reboot**, but the reboot does **not** fix the+multi-container `SIGTRAP` above — that is a toolchain regression, not a wedged+service.++**Until it's fixed (proper fix is out of scope of any one feature — it's a+project-wide test-harness change):** verify SwiftData-touching work with+`make build` + `build-for-testing` + `make lint`, and treat a full-suite/whole-+suite crash as the toolchain, not a code failure. Do **not** burn time fighting+the simulator. A real fix likely means a shared test-support container/schema+factory used by every suite (and confirming it survives multi-suite runs).++**Operational caution:** do not run two `xcodebuild test` invocations against the+same simulator concurrently (parallel worktrees) — they race and can wedge+CoreSimulator. Also scope any `pkill` to Scramble (`pkill -f "xcodebuild.*Scramble"`),+not a blanket `pkill -f xcodebuild`, which will kill unrelated projects' test runs+on a shared machine.++See `persistence.md` for the SchemaV3 / no-SchemaV4 model rules.
CHANGELOG.md Modified +13 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex a55b440..65cf59e 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- `packing-item-subitems` (T-1587) — 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. Implemented across three phases:+  - **Model & domain** — `Scramble/Scramble/Models/TripPackingItem.swift` gains `note: String?` and `subItemsData: Data?` (both Optional/nil-default, riding on the existing `SchemaV3` via lightweight inference — no `SchemaV4`, per the duplicate-checksum precedent) plus a `subItems: [String]` `CodableBridge` (getter normalises a nil/empty `Data()` to `[]`, setter stores nil for an empty list). New `Scramble/Scramble/Models/PackingSubItems.swift` — pure, view/`ModelContext`-free helpers `sanitizedEntry` / `appending` (→ `AddOutcome`) / `removing(at:)` / `sanitizedNote` with caps `maxCount = 50`, `maxItemLength = 200`, `maxNoteLength = 500` (grapheme-cluster capping, duplicate-preserving, positional removal). Tests: `PackingItemContentBridgeTests`, `PackingSubItemsTests` (incl. a property-based encode/decode round-trip).+  - **Sync & migration** — `TripPackingItemRecordTranslator` carries `note` (clearable `CKRecordValue?`) and `subItemsData` (written only when non-empty, guarded by `kRecordBlobSizeCap`; nil or a non-nil empty `Data()` both serialise as an absent field) with unconditional `from` assignment so a clear propagates across devices (the `masterItemID` precedent; documented as requiring a full server snapshot). `ZoneMigrationCoordinator.relocateTrip` copies `note` (via init) and `subItemsData` onto the relocated item so the V2→V3 clone no longer drops them. Tests across `TripPackingItemRecordTranslatorTests`, `ZoneMigrationCoordinatorPhase51Tests`, `SchemaV3MigrationTests`, `RulesEngineRunnerTests`.+  - **UI** — new `Scramble/Scramble/Components/PackingSubItemsView.swift`, purely presentational (plain-value params, no `@Model`): renders the note (tappable → edit on interactive rows), sub-item rows, and a reveal-on-tap add field (`@FocusState`, live 200-grapheme cap, hidden at 50 items); row-local UUID-keyed `SubItemDraft`s re-seeded on `.onChange(of: subItems)`, `ForEach` by stable id (never `id: \.self`), positional remove for duplicate safety; sub-item list is a `.contain` a11y container with per-entry Remove actions. `PackingItemForm` gains a vertical note `TextField` (add + edit modes, 500-cap) wired through `performAdd` / `performEdit`. `PackingItemRow` embeds the view (reads `item.note` / `item.subItems` directly for SwiftData observation) and restructures its a11y; `PackingItemGroup.addSubItem` / `removeSubItem` mutate via `PackingSubItems` and commit through the `LocalWriteHook` chokepoint without changing state or group; the sheet background tap-catcher dismisses the active add field. Tests: `PackingFormSaveTests`, `PackingItemRowAccessibilityTests`, `PackingSubItemsUITests`.+  - `specs/OVERVIEW.md` — status set to `Done`.+ - `copy-master-packing-items` — pre-push review refinements: the target-person picker rows now show each person's `PersonAvatar` (matching every other person-row surface); sheet eligibility is computed once per body instead of per row; `MasterPackingList.performCopy`'s branch selection is extracted into a pure, unit-tested `copyOutcome(createdCount:save:runEngine:)` seam (covering the all-skipped, save-failed, and deferred-recompute paths); a `Person.displayName` helper de-duplicates the "Unnamed" fallback; and `docs/agent-notes/rules-engine.md` records the new `performCopy` recompute trigger. The feature's three-level implementation write-up lives in `specs/copy-master-packing-items/implementation.md`.  - `copy-master-packing-items` — Master Lists UI phase (completes the feature):@@ -26,6 +32,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).   - `specs/copy-master-packing-items/tasks.md` — 9 TDD tasks across 2 streams.   - `specs/OVERVIEW.md` — added the spec to the table (status `Planned`) and detail sections. +### Changed++- `packing-item-subitems` (T-1587) — packing-sheet note / sub-item interaction reworked from the initial reveal-on-tap affordance row into compact trailing glyphs, after device review:+  - `PackingItemRow` now shows a **note glyph** (`note.text`, tinted person-colour when a note exists, secondary when empty) and a **sub-item list glyph** (`list.bullet`) just left of the Skip button, replacing the persistent "+ add item" row that pushed every interactive item onto two lines. Each glyph reveals its inline editor on demand and they are mutually exclusive.+  - The **note is edited inline** via the note glyph (or by tapping the note text) instead of opening the full edit form — a new `PackingItemGroup.saveNote` commits `PackingSubItems.sanitizedNote(_:)` through the same `LocalWriteHook` chokepoint; the edit form still edits the name. `PackingSubItemsView` gains an inline note `TextField` (vertical, live 500-cap via the new `PackingSubItems.cappedNote`) alongside the existing sub-item add field, both parent-controlled via `isEditingNote` / `isAddFieldVisible` bindings; the sheet's background tap-catcher dismisses either.+  - Layout fixes: the item name is given a 44pt min-height so it centres with the checkbox + glyphs; the sub-item row is centre-aligned so the Remove `−` lines up with the entry text; the note + sub-items are indented 56pt to sit under the item name; and note / sub-item text is bumped from `.footnote` to `.subheadline` for readability.+ ### Fixed  - On-device launch failures (SwiftData migration + CloudKit):

Things to double-check

Note editor save-on-blur under direct editor switch.

Tapping the list glyph while the note field is focused should resign first responder → noteFocused=falseonSaveNote fires before the field is torn down. Confirm on device that an in-progress note isn't lost when switching straight to the sub-item field.

Three trailing controls under large Dynamic Type.

checkbox + name + note glyph + list glyph + Skip can get tight on narrow widths / AX text sizes. Eyeball the largest Dynamic Type setting.