Skip to content

User testing polish: Photos tab, Settings, Widgets, Tags, and bug fixes#32

Merged
j23n merged 6 commits into
mainfrom
claude/user-testing-polish-and-bugfixes-2026-04-28
Apr 28, 2026
Merged

User testing polish: Photos tab, Settings, Widgets, Tags, and bug fixes#32
j23n merged 6 commits into
mainfrom
claude/user-testing-polish-and-bugfixes-2026-04-28

Conversation

@j23n

@j23n j23n commented Apr 28, 2026

Copy link
Copy Markdown
Owner

Summary

Polish pass covering all findings from a user testing session, plus two bugs found after.

Photos tab

  • Removed the select, grid-size, and jump-to-top toolbar buttons
  • Select moved into the photo context menu
  • Grid size now controlled by pinch-to-zoom (MagnificationGesture on the ScrollView)
  • Jump to top now fires when tapping the two-line principal nav item (title + visible date range) that appears once the in-content heading scrolls out of view — matching the Collections/Folder pattern
  • Removed .softTopScrollEdge() blur at the top of the grid
  • Fixed landscape detection: was geo.size.width > geo.size.height which returned true whenever the keyboard shrank the view height; now uses @Environment(\.verticalSizeClass) == .compact
  • Fixed tag deep-link re-seeding: replaced activeTags.isEmpty guard with a hasSeededInitialTags flag so removing all chips no longer triggers a re-seed

Settings

  • Reload Library button is disabled while a scan is in progress; a ProgressView row appears during the scan
  • "Last Synced" shows an absolute timestamp instead of a relative one
  • Removed the "LocalGallery — read-only viewer" footer section
  • Version string simplified to CFBundleShortVersionString only (e.g. 1.0.0)

Widgets

  • Moved the photo out of the content layer and into .containerBackground across all three widgets (MemoriesWidget, FolderWidget, TagsWidget) — this is what caused the black bars: iOS 17+ automatically adds safe-area padding around content-layer views, but not around containerBackground
  • Split WidgetHeroView into WidgetBackgroundImage (goes in containerBackground, renders the photo edge-to-edge with a bottom gradient scrim) and a caption-only WidgetHeroView (title + subtitle + optional glyph)
  • Memories widget: replaced the synthetic "Recently" fallback item with the first real stored memory (trip, person, folder, etc.) so the widget never shows invented content

Tags

  • Extended hierarchical virtual-parent tag generation (previously Places/* only) to also cover Objects/* and Scenes/* in TagIndex
  • Extended the corresponding prefix-match filter in PhotoGridScreen.recomputeFilter to the same three namespaces

Bug fixes

  • Slideshow flash when navigating backwards: the crossfade underlay was always photos[(index-1+count)%count], so calling goPrev() immediately moved the underlay to index-2 before the fade completed, flashing the wrong photo. Fixed with an underlayIndex state variable that captures the current photo before index changes, used in goPrev(), goNext(), and the timer auto-advance
  • Empty memory card placeholders in Collections: MemoryCardView.coverURL returned nil whenever the cover photo's UUID couldn't be found in the search index (e.g. photo moved/renamed, changing its SHA-256-derived UUID). Fixed by falling back to the first resolvable photo in memory.photoIDs before showing the empty gray rectangle

Test plan

  • Photos tab: pinch in/out changes grid density; no grid-size button in toolbar
  • Photos tab: long-press a photo → context menu includes "Select"; tapping it enters select mode with that photo pre-selected
  • Photos tab: scroll past the large "Photos" heading → two-line principal item (title + date range) appears; tap it → scrolls to top
  • Photos tab: no blur/frosted bar at the top of the scroll content
  • Photos tab: open keyboard (via search) in landscape → grid doesn't change column count
  • Photos tab: deep-link with tags → chips appear; removing all chips doesn't re-add them
  • Settings: tap Reload → button grays out and spinner row appears; both clear when scan finishes
  • Settings: "Last Synced" shows a date/time string, not "X minutes ago"
  • Settings: no footer section with "LocalGallery — read-only viewer"
  • Settings: version shows e.g. 1.0.0, no build number in parentheses
  • Widgets: all three widget sizes show the photo edge-to-edge with no black bars
  • Memories widget: never shows a card titled "Recently" — shows a real memory title
  • Collections: memory cards with a moved/deleted cover photo show a fallback thumbnail instead of a blank gray card
  • Collections → memory slideshow: tap left repeatedly — no flash of a wrong photo before each crossfade

🤖 Generated with Claude Code

Photos tab
- Replace select/grid-size/jump-to-top toolbar buttons with: context-menu
  Select, pinch-to-zoom grid size, tappable two-line principal nav item
  (title + visible date range) that scrolls to top on tap
- Remove .softTopScrollEdge() blur at the top of the grid
- Fix landscape detection using verticalSizeClass instead of geometry ratio
  (the old approach broke when the keyboard shrank the view height)
- Fix tag-filter deep-link re-seeding: use hasSeededInitialTags flag instead
  of activeTags.isEmpty so removing all chips doesn't trigger a re-seed

Settings
- Disable Reload button and show ProgressView while scanning
- Show last-synced as an absolute timestamp (was relative)
- Remove "LocalGallery — read-only viewer" footer
- Simplify version string to CFBundleShortVersionString only

Widgets
- Move photo into containerBackground across all three widgets so the image
  fills edge-to-edge without the black bars iOS 17+ adds around content-layer
  views; split WidgetHeroView into WidgetBackgroundImage + caption-only HeroView
- Replace synthetic "Recently" Memories-widget fallback with the first real
  stored memory (trip, person, folder, etc.)

Tags
- Extend hierarchical prefix matching (Places/*) to Objects/* and Scenes/*
  in both TagIndex virtual-parent generation and PhotoGridScreen filter

Bug fixes
- Slideshow: add underlayIndex state so the crossfade underlay always holds
  the "from" photo; previously goPrev() immediately swapped it to index-2,
  causing a visible flash of the wrong image before the fade completed
- Collections memory cards: fall back to any resolvable photo in photoIDs
  when coverPhotoID can't be found (happens when a photo is moved/renamed,
  changing its SHA-256 UUID), preventing empty placeholder cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@j23n

j23n commented Apr 28, 2026

Copy link
Copy Markdown
Owner Author

Review

Targeted polish pass — scope is well-bounded and the description maps cleanly onto the diff. Below are findings from a read-through.

Correctness — looks good

  • Slideshow underlay fix (MemorySlideshowView.swift:248,257,291) — underlayIndex = index is captured before the animated index mutation in all three call sites (goPrev, goNext, timer auto-advance). Correct fix for the flash.
  • Memory cover fallback (CollectionsView.swift:525) — lazy.compactMap short-circuits cleanly.
  • Tag re-seed flag (PhotoGridScreen.swift:57,295) — @State flag resets on parent re-mount via .id(seedTags), so a new deep link still seeds.
  • Landscape detection (PhotoGridScreen.swift:198) — verticalSizeClass == .compact is the canonical check; no more false positives when the keyboard shrinks height.
  • Widget containerBackground — moving the photo out of the content layer is the right fix for iOS 17+ safe-area padding. Caption scrim now lives in WidgetBackgroundImage, content layer uses bottomLeading framing — composes cleanly.

Worth considering

1. rescan() no longer silent — possible side effect on pull-to-refresh
GalleryStore.swift:392 flips silent: true → silent: false. This is what enables the new Settings spinner row, but rescan() is also called from the Photos tab's .refreshable { await store.rescan() }. That now sets isScanning = true during a normal pull-to-refresh too. If any other view binds to store.isScanning, scan UI will show during pull-to-refresh. Worth confirming — if undesirable, thread silent through rescan(silent:) and only make Settings' Reload non-silent.

2. Duplicated namespace allowlist
The "is hierarchical (prefix-match) namespace" rule is spelled twice:

  • TagIndex.swift:41-43isPlaces || namespace==\"objects\" || namespace==\"scenes\"
  • PhotoGridScreen.swift:646 — same triple

Guaranteed to drift the next time a namespace is added. Suggest a single source of truth — e.g. a var matchesByPrefix: Bool on TagNamespace/HierarchicalTag, or a Set<String> constant. The new isPrefixMatch name in PhotoGridScreen reads better than isPlaces; the TagIndex local could share it.

3. Pinch gesture is one-step per pinch
PhotoGridScreen.swift:282-289 triggers only onEnded and steps sizeTier by ±1 regardless of pinch magnitude. Big and tiny pinches behave identically, and there's no live feedback during the gesture. Acceptable for a first cut; a continuous .onChanged mapping magnitude → tier delta and committing in .onEnded would feel closer to system Photos.

4. Scroll-to-top discoverability when showVisibleDateRange == false
The principal nav-item tap is the only in-app way to scroll to top now, gated on showVisibleDateRange && !selectMode. Worth confirming all Photos-tab variants set showVisibleDateRange = true, or accept the iOS status-bar tap as the fallback for the rest.

5. Last Synced formatting
LabeledContent(\"Last Synced\", value: lastSync, format: .dateTime) uses the default .dateTime style, which renders e.g. Apr 28, 2026 at 3:45 PM — long enough to wrap on narrow widths. Consider .dateTime.day().month().hour().minute() if compactness matters.

6. Memory fallback exclusion list
WidgetSnapshotExporter.swift:314memories.first { \$0.type != .onThisDay && \$0.type != .yearsAgo }. As MemoryType grows, an opt-out list silently includes new types as fallback candidates. An opt-in (.trip, .person, .folder, .birthday) is slightly more defensive, but current is acceptable.

Tests / security / performance

No tests added — consistent with existing coverage for UI/widget code. No new I/O patterns or concurrency changes. No security-relevant changes.

Summary

LGTM with two non-blocking items worth picking up before merge: (1) verify silent:false on rescan() doesn't leak scan UI into Photos-tab pull-to-refresh, and (2) DRY the prefix-match namespace list shared between TagIndex and PhotoGridScreen. The rest is style/UX polish that can land as-is or in a follow-up.

j23n and others added 5 commits April 28, 2026 17:10
…dback, date format, opt-in fallback

- rescan(silent:) — add parameter (default true) so pull-to-refresh stays
  silent and only Settings' Reload button triggers the isScanning spinner;
  fixes unintended scan UI leak into the Photos tab refreshable

- TagNamespace.matchesByPrefix — single source of truth for the three
  hierarchical namespaces (Places, Objects, Scenes); replaces the duplicated
  triple-check in TagIndex and PhotoGridScreen so new namespaces only need
  to be added in one place

- Pinch gesture live feedback — switch from onEnded-only to onChanged with
  a pinchBaseTier captured at gesture start; cells now resize in real time
  during the pinch, matching the feel of system Photos; large pinches
  (>1.5x, <0.67x) jump two tiers at once

- Last Synced format — compact .month(.abbreviated).day().hour().minute()
  instead of the full .dateTime default (removes year and "at", prevents
  wrapping on narrow widths)

- Widget memory fallback — convert from opt-out (exclude onThisDay/yearsAgo)
  to opt-in set [.trip, .personOverTime, .folderEvent, .photoDensity, .birthday]
  so future MemoryType additions don't silently appear as fallback candidates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When photos are moved or renamed their SHA-256 UUID changes, leaving cached
memories pointing at IDs that no longer exist in searchService.photoByID.
MemoryCardView already falls back through photoIDs to find any resolvable
photo for the card image, but when every ID in a memory is stale it still
shows a gray empty placeholder.

visibleMemories now mirrors that resolution order: keep a memory only if
coverPhotoID or any photoID resolves to a known photo. Fully-stale memories
are hidden rather than shown as gray cards. They reappear correctly after
the next memory regeneration run (which uses the current photo UUIDs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…card visual

Scoring
- personOverTime: +20 flat bonus (was pure formula) — people memories now
  rank well above folder memories regardless of library size
- trip / subtrip base bonus: 18 (was 8) — trips likewise clear folders
- folderEvent: multipliers halved (0.4/photo, 1.0/span-day) so folders only
  surface when no better content exists

These bring the approximate rank order to:
  birthday (100+) > onThisDay > personOverTime/trip > yearsAgo > photoDensity > folderEvent

Cap
- Top-N reduced from 20 to 10; the rail stays scannable and higher-quality
  memories aren't buried behind a wall of folder cards

Empty card visual
- When coverURL is nil (thumbnail load failure) show a photo.on.rectangle
  SF Symbol centred on the placeholder rectangle instead of the near-invisible
  bgGrouped fill that blended into the bg background and appeared as a gap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Redesigned icon: beige folder with polaroid (left lip) on orange
  gradient background
- AccentColor asset now defines orange (0.769, 0.541, 0.243) with
  dark mode variant; Design.accentColor reads from asset via
  Color("AccentColor") so changes propagate app-wide
- Nav bar standard appearance uses opaque background at 0.90 alpha
  for better readability of inline title over photo grid

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three fixes for scroll performance, especially noticeable on the 20k All
Photos grid:

1. PhotoGridScreen: decouple section visibility tracking from @State.
   The per-cell onAppear/onDisappear was mutating @State on every cell
   enter/exit, forcing SwiftUI to diff the entire 20k-item ForEach tree
   every frame. Replaced with a non-observed SectionTracker class; the
   date-range string syncs to @State on a 300ms debounce (~3 re-evals/s
   instead of dozens).

2. PhotoGridScreen: keep the live date-range Text out of the ScrollView
   content on the Photos tab. The toolbar principal already handles it;
   having a changing sibling inside the scroll content was invalidating
   the LazyVGrid layout.

3. ThumbnailService: force-decode disk-cached thumbnails via ImageIO with
   kCGImageSourceShouldCacheImmediately instead of UIImage(data:). The
   lazy UIImage created by UIImage(data:) deferred JPEG decode to the
   render pipeline, where it ran unbounded for every visible cell,
   exhausting IOSurfaces (the CMPhotoJFIFUtilities -17102 cascade).
   Also added a DecodeLimiter actor (limit 4) for fresh thumbnail
   generation from source images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@j23n j23n merged commit c47e739 into main Apr 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant