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).
onChange source of truth.item.subItems JSON-decoded 3× per row render — fixed by threading the hoisted value into rowContent (now 1×).design.md banner.PackingItemGroup.saveNote, note glyph) has no executable test yet — flagged, not blocking (harness down on Xcode 26.5).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.
00784c1 Address pre-push review findings fa8cdbd Rework packing note/sub-item UI into trailing glyphs dd2def7 Add packing sub-items + note UI (UI phase, tasks 9-13) 4b82b48 Sync + migration for packing-item note/subItems 02668a2 Add note + subItems fields to TripPackingItem 475f58f add PackingSubItems pure helpers + tests 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.
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.
Two new Optional properties on TripPackingItem — note: 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.
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.
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.
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.
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.
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.
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
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
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
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
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)
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.
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.
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.)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).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | PackingItemRow / PackingSubItemsView / PackingSheet — editor switch | Inline-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. |
| minor | PackingItemRow — render hot path | item.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. |
| major | decision log | The UI rework (commit fa8cdbd) contradicts Decision 13 with no logged decision. | Added Decision 14 (supersedes Decision 13). |
| major | design.md | design.md still described the superseded reveal-on-tap / form-based note approach unannotated. | Added a reworked-post-design banner pointing to Decision 14. |
| minor | tests — PackingSubItems | PackingSubItems.cappedNote had no coverage. | Added cappedNote cap + no-trim tests. |
| minor | docs/agent-notes | No agent-note on the two inline editors or the direct item.note/subItems observation reads. | Added a packing-sheet.md section. |
| minor | PackingSubItems vs PackingItemForm | cappedEntry/maxItemLength (200) mirrors cappedName/nameLimit (200). | Skipped — conceptually distinct caps with documented intentional mirroring; coupling them would be worse. |
| minor | PackingItemRow / PackingSubItemsView — a11y modifiers | Five tiny *AccessibilityAction structs share one shape; could be a single generic. | Skipped — established per-action convention in the file (three predate the branch). |
| minor | PackingItemRow / PackingSubItemsView — copy-paste | noteEditor 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. |
| major | tests — inline note path | PackingItemGroup.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. |
| minor | PackingSubItemsView — keyboard | design'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. |
Click to expand.
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+ }+ }+}
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+ }+ }+}
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)
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 {
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))+ }+}
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
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) }
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) }
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"+ ),+ ]+ }+}
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+ }+}
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")
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 {
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")+ } }
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")
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(
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()
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)"+ )+ }+}
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.
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).
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.
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.
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):
Tapping the list glyph while the note field is focused should resign first responder → noteFocused=false → onSaveNote 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.
checkbox + name + note glyph + list glyph + Skip can get tight on narrow widths / AX text sizes. Eyeball the largest Dynamic Type setting.