Commit Graph

13991 Commits

Author SHA1 Message Date
Claude fc8f057e3e feat: add Public chip to onchain wallet card
The onchain wallet's Taproot address is derived from the account's Nostr
pubkey, so anyone with the npub can see its balance and transaction
history on-chain. Surface that fact directly on the card with a tappable
"(i) Public" chip that opens a dialog explaining the privacy implication
and recommending private (non-Nostr) channels for funding and draining.
2026-05-20 16:39:34 +00:00
Claude ec28d4eb4c feat: redesign payment targets modal with QR, copy and pay buttons
Each target now renders in a single row with the payment method and
address on the left and three trailing icon buttons: QR (opens a dialog
showing the raw address as a QR code), Copy (clipboard + toast), and
Pay (existing payto:// intent). Adds QrCode2 to MaterialSymbols.

https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
2026-05-20 16:35:47 +00:00
Vitor Pamplona baf043343d Merge pull request #3009 from greenart7c3/claude/fix-reaction-payment-targets-UhsYP
fix: surface Pay row in Reaction Settings for existing accounts
2026-05-20 11:59:29 -04:00
Claude 59fd1f2244 build: use directories.add for benchmark androidTest resources
srcDirs(vararg) on AGP 9 source-set Resources is deprecated; switch to
the directories MutableSet API. Preserves the original behavior of
including quartz/src/androidDeviceTest/resources in :benchmark's
androidTest java resources (verified bip39.vectors.json shows up in
processBenchmarkAndroidTestJavaRes output).
2026-05-20 15:56:53 +00:00
Claude ebfd066d5c build: convert remaining .gradle files to Kotlin DSL
Converts the last four Groovy build scripts to Kotlin DSL so the entire
build is consistent with the rest of the modules (commons, quartz, etc.).

- settings.gradle -> settings.gradle.kts
- build.gradle (root) -> build.gradle.kts
- amethyst/build.gradle -> amethyst/build.gradle.kts
- benchmark/build.gradle -> benchmark/build.gradle.kts

Notes:
- Switched the per-subproject spotless format from groovyGradle to
  kotlinGradle now that the only gradle scripts are .gradle.kts.
- benchmark module: moved targetSdk from defaultConfig to testOptions —
  AGP 9 removed targetSdk from library defaultConfig.
- Reimplemented getCurrentBranch() with ProcessBuilder (Groovy's
  String.execute() isn't available in Kotlin DSL).
2026-05-20 14:17:47 +00:00
Claude 5841ca652c fix: surface Pay row in Reaction Settings for existing accounts
Accounts whose reactionRowItems were persisted before ReactionRowAction.Pay
existed still load a 5-item list, so Pay never appears in Reaction Settings.
Add mergeWithDefaultReactionRowItems mirroring mergeWithDefaultVideoPlayerButtons
and apply it where reaction rows enter the StateFlow (constructor + updateFrom),
appending any default actions missing from the saved list while preserving
the user's existing order and toggles.
2026-05-20 14:13:03 +00:00
Vitor Pamplona fb4dfcefec Merge pull request #3006 from vitorpamplona/claude/add-thread-name-crashes-HsFy0
Include thread name in crash reports
2026-05-20 10:10:44 -04:00
Claude c0a22e5c0b feat: include crashing thread name in crash report
Capture the crashing thread's name from the UncaughtExceptionHandler and
surface it in the report (both in the device-info table and at the top of
the stack-trace block), so main-thread crashes can be distinguished from
coroutine-worker crashes at a glance.
2026-05-20 13:53:11 +00:00
Vitor Pamplona a4be1d21fe Merge pull request #3003 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 09:49:27 -04:00
Crowdin Bot ce9a7da265 New Crowdin translations by GitHub Action 2026-05-20 13:46:35 +00:00
Vitor Pamplona 33e1790348 Merge pull request #3005 from vitorpamplona/claude/fix-motorola-runtime-exception-Grtj3
Handle ForegroundServiceStartNotAllowedException in PlaybackService
2026-05-20 09:44:56 -04:00
Vitor Pamplona 071b88c7eb Merge pull request #3004 from vitorpamplona/claude/modernize-zap-popup-QZWMV
Refactor ZapAmountChoicePopup UI with extracted components
2026-05-20 09:44:47 -04:00
Claude 754e03767a Revert "feat: replace zap button long-press-to-edit with long-press-to-custom"
This reverts commit 86a8fb0743.
2026-05-20 13:37:18 +00:00
Claude 86a8fb0743 feat: replace zap button long-press-to-edit with long-press-to-custom
The new Tune button inside the zap popup already opens the quick-zap
amounts editor, so the long-press shortcut from the zap button itself
(and from each chip inside the popup) is now redundant. Drop those
gestures and replace them with the custom-amount dialog that used to
live behind double-click:

  - ZapReaction's button: long-press now opens ZapCustomDialog;
    double-click is gone.
  - Each chip in the popup: long-press now opens ZapCustomDialog
    (via a new onCustomAmount callback threaded through the popup
    overloads).
  - NestActionBar's zap button wires onCustomAmount to its existing
    wantsToSetCustomZap state.
  - ReusableZapButton has no custom-zap dialog, so chip long-press
    just dismisses the popup there.
2026-05-20 13:33:01 +00:00
Claude 7403ccc825 fix: shrink Tune settings icon in zap popup to match chip size
The Tune button used the 40dp reactionBox with a 28dp icon, which was
visibly larger than the surrounding bolt chips. Use a compact 32dp box
with a 18dp icon so it lines up with the bolt glyph inside each chip.
2026-05-20 13:18:58 +00:00
Claude 6be8f2db9e fix: vertically center items in zap amount popup row
The bolt-icon pill chips and the Tune settings button have different
intrinsic heights, so FlowRow's default top alignment leaves the
settings button visually higher than the chips. Set itemVerticalAlignment
so every item is centered within the row.
2026-05-20 13:17:37 +00:00
Claude dde539f0fd fix: catch ForegroundServiceStartNotAllowedException in PlaybackService
Media3's MediaSessionService.onStartCommand calls stopSelfSafely() when
delivered an intent (e.g. MEDIA_BUTTON from a headset) while no playback
is active. stopSelfSafely() invokes startForeground() to detach the
notification before stopping, which Android 12+ rejects when the app is
backgrounded with ForegroundServiceStartNotAllowedException.

Swallow that case in onStartCommand and stopSelf, matching the pattern
already used in NotificationRelayService.

https://claude.ai/code/session_01KTZDxK7zacrhFfqMHqt8dm
2026-05-20 13:06:35 +00:00
davotoula e131b0fec2 Show on chain zaps even if only reaction 2026-05-20 08:23:10 +02:00
Vitor Pamplona 4f094929d6 Merge pull request #3002 from vitorpamplona/claude/fix-locale-observable-by5fc
fix: observe locale in CalendarDateTimePickerButton
2026-05-19 20:10:17 -04:00
Claude 446345b35e feat: modernize zap amount choice popup to match reactions popup style
Wrap the zap-amount choices in the same elevated card / surfaceVariant
container used by the reactions popup, replace the flat primary-colored
material Buttons with pill-shaped Bitcoin-orange chips that pair a bolt
icon with the amount, and add a Tune action at the end of the row (the
zap equivalent of the AddReaction button in the reactions popup) so the
user can jump straight to editing their quick-zap amounts.
2026-05-20 00:01:17 +00:00
Vitor Pamplona d4f7f9f178 Merge pull request #3001 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 19:46:46 -04:00
Crowdin Bot d4f7f745af New Crowdin translations by GitHub Action 2026-05-19 23:34:03 +00:00
Vitor Pamplona 3a07b1879e Merge pull request #2996 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 19:32:34 -04:00
Crowdin Bot 9099fd4b84 New Crowdin translations by GitHub Action 2026-05-19 23:27:44 +00:00
Vitor Pamplona 6f4978f149 Merge pull request #3000 from vitorpamplona/claude/revert-commit-32f7bda-ObZBH
Remove I2P support and privacy routing abstraction
2026-05-19 19:25:50 -04:00
Claude ec3be14cf8 fix: observe locale in CalendarDateTimePickerButton
Use LocalConfiguration.current.locales[0] so the date formatter recomposes
when the device locale changes, satisfying the NonObservableLocale lint
check.
2026-05-19 23:24:57 +00:00
Vitor Pamplona 2ff67479a3 Merge pull request #2999 from vitorpamplona/claude/fix-notification-feed-bug-YNke0
Fix notification feed composition issue by removing CrossfadeIfEnabled
2026-05-19 19:16:13 -04:00
Claude 6a0801427b Revert "Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7"
This reverts commit d42482ff56, reversing
changes made to a8b6766f49.
2026-05-19 23:10:56 +00:00
Claude 9b94874d01 fix(notifications): drop crossfade around the card feed
The Notifications feed could occasionally render as two stacked
LazyColumns — visible as ghosted text and "only the top one scrolls"
because both LazyColumns share the same `listState`. Reproduced by
double-tapping the bottom-nav Notifications button (each tap calls
`invalidateDataAndSendToTop(true)` → `clear()` + `refreshSuspended()`).

`RenderCardFeed` wrapped its `when (feedState)` in `CrossfadeIfEnabled`
→ `MyCrossfade`. `CardFeedState.Loaded` is a plain class with identity
equality, so back-to-back refreshes that transit through `Empty` or
`Loading` and back to `Loaded` inside the 100 ms animation can leave
two `Loaded` instances pinned in `currentlyVisible`. Each one composes
its own `FeedLoaded { LazyColumn(...) }`, both bound to the same
`LazyListState`, and only the topmost gets scroll input.

Switch directly on `feedState` with no crossfade — the animation was
only 100 ms and barely perceptible. Other CrossfadeIfEnabled callers
are untouched.
2026-05-19 23:00:50 +00:00
Vitor Pamplona 679c5a89e0 Merge pull request #2994 from vitorpamplona/claude/nip52-calendar-screens-yQc3j
Add NIP-52 calendar events support with UI and reminder system
2026-05-19 18:30:59 -04:00
Vitor Pamplona 297c2adffa Merge pull request #2998 from vitorpamplona/claude/fix-remember-return-type-l5SvB
Fix PrivacyOptionsScreen initialization with LaunchedEffect
2026-05-19 18:28:42 -04:00
Claude 952aa24001 fix(privacy): use LaunchedEffect instead of remember for VM reset
The remember(...) call returned Unit, which tripped the
RememberReturnType lint. Use LaunchedEffect, which is the
idiomatic Compose API for keyed side effects.
2026-05-19 22:27:13 +00:00
Vitor Pamplona bef3bd61c5 Merge pull request #2997 from vitorpamplona/claude/fix-completion-handler-exception-6WASO
Simplify Nip11Retriever by removing nested withContext
2026-05-19 18:23:42 -04:00
Claude 97a6d4bcbd fix(calendars): wire feeds into updateFeedsWith so new events stream in live + drop redundant Calendar Lists label
Two things turned out to be the same bug:

1. New calendar events arriving from relays didn't show up in either
   feed without a manual pull-to-refresh. The screen was stuck on
   whatever the last full refresh saw.
2. After a top-nav filter switch the feed looked like it was reflecting
   "a previous state" — same root cause: filter switch ran a full
   refresh from LocalCache, but events that the new subscription
   subsequently delivered were dropped on the floor.

AccountFeedContentStates.updateFeedsWith / deleteNotes had calls for
every other feed but neither calendarAppointmentsFeed nor
calendarCollectionsFeed — so LocalCache.live.newEventBundles flowed
past them. Added both calls (and the matching deleteFromFeed entries)
so new appointments/collections insert into the visible feed live.

Also dropped the redundant "Calendar Lists" label above the top-nav
filter spinner on the collections screen — it duplicated the screen
title shown by the navigation chrome.
2026-05-19 22:21:23 +00:00
Claude 62da33f02b fix(relay-info): switch to Dispatchers.IO around the whole executeAsync call
When loadRelayInfo was invoked from a main-thread Compose coroutine and
the call got cancelled, okhttp's executeAsync cancellation handler
closes the Response on the resuming dispatcher (AndroidUiDispatcher).
Closing the response drains any unread bytes from the SSL socket, which
triggers a blocking read on the main thread and throws
NetworkOnMainThreadException — surfaced to the user as
CompletionHandlerException.

The previous withContext(Dispatchers.IO) was nested inside the .use {}
block, so neither the initial suspension at executeAsync() nor the
implicit response close in the cancellation handler ran on IO. Moving
the dispatcher switch to wrap the entire function ensures the
continuation is dispatched on IO and the cancellation/close path runs
there too.
2026-05-19 22:13:58 +00:00
Crowdin Bot 5045ef9dcf New Crowdin translations by GitHub Action 2026-05-19 22:08:49 +00:00
David Kaspar 3a84aa0168 Merge pull request #2991 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 00:07:19 +02:00
Vitor Pamplona 1909fe7e4c Merge pull request #2995 from vitorpamplona/claude/test-mls-reply-notifications-SnaIM
Add reply support for Marmot/MLS group messages
2026-05-19 18:07:17 -04:00
Claude c7a4796b25 refactor(marmot): audit follow-ups on MLS reply paths
Self-audit of the previous MLS reply commit surfaced four concrete
improvements:

1. Push-notification cold-start replies now thread reliably. The
   receiver previously rebuilt the parent inner event by looking it up
   in LocalCache; in a cold-process broadcast that cache hasn't been
   re-hydrated yet (Account.restoreAll runs async on init), so the
   q-tag silently dropped. Carry the parent's inner event id AND
   author pubkey through the Intent extras and feed them straight to
   MarmotManager.buildTextMessage, which now takes (eventId, author)
   instead of a full Event. The cold-start reply is now always
   threaded, not just the warm-cache case.

2. marmotGroupRelays() is no longer duplicated. The receiver was
   reimplementing what AccountViewModel had as a private fun (which
   itself was used 9× inside the VM). Lifted to Account so headless
   callers can reach it without spinning up a ViewModel; both sites
   now share one implementation.

3. Dropped the dead editFromDraft / draftId plumbing. Added for route
   symmetry with NIP-17 but Marmot has no draft persistence, so the
   parameter rode all the way through MarmotGroupChatView only to be
   ignored under @Suppress("UNUSED_PARAMETER"). Per CLAUDE.md, don't
   pre-emptively abstract; reinstate when drafts actually land.

4. MarmotGroupMessageComposer no longer has default-param `remember`
   blocks. There's exactly one caller and it always passes both
   messageState and replyTo — the defaults were just noise.

The in-chat send path also captures (id, pubKey) under the replyTo
guard before launching the send coroutine, so a slow send + a user-
cleared reply state can't race into a partially-threaded message.

No protocol or behavioral change for the warm-cache happy path; the
threading improvement is observable only on cold-start push-reply.
2026-05-19 22:04:14 +00:00
Claude cc78744a10 fix(calendars): tapping a Calendar List opens the dedicated detail screen, not the generic thread view
Calendar collections (kind 31924) on the Calendar Lists screen routed
through Route.Note → ThreadView, which rendered them as a regular note
with the calendar metadata block at the top — no idea that the
collection is a curated list of appointments.

Fix:
- CalendarCollectionsView: route taps to Route.CalendarEventDetail
  (using the collection's own Address) like every other calendar surface.
- CalendarEventDetailScreen: branch on event kind. CalendarEvent (31924)
  now renders a new CollectionBody — title, description, ReactionsRow,
  and a list of the collection's member appointments. Each member row
  uses observeNote so an appointment that arrives later in the session
  fills in without leaving the screen; tapping a row routes to its
  appointment detail.
2026-05-19 21:58:35 +00:00
Claude 382729520c chore(commons): regenerate Material Symbols font subset to include EventAvailable
The "Add to phone calendar" icon I added on the calendar detail screen
top bar mapped to MaterialSymbols.EventAvailable (\\uE614), but the
checked-in TTF was a subset built before that codepoint existed in
MaterialSymbols.kt — so the third icon from the right rendered as the
.notdef placeholder.

Regenerated via tools/material-symbols-subset/subset.sh. The subset is
now 214 codepoints (up from 213); file size unchanged at 420K.
2026-05-19 21:58:35 +00:00
Claude 1b563fb3f0 fix(calendars): clear EOSE cursor on list switch so evicted notes get re-fetched
LocalCache stores notes via WeakReference (LargeSoftCache), so while the
user is on People List B the GC can reclaim the global calendar notes
that aren't strong-referenced from B's UI. When the user flips back to
Global, the relay subscription rebuilds with `since = lastEose` for
Global — which still says "you already have everything up to T" from the
prior visit. The relay won't re-send events with createdAt < T, and the
ones that were evicted from LocalCache stay gone. Symptom: switching
back to Global shows fewer events, or only past events, or nothing.

Fix: when the calendar listName changes, drop the EOSE cursor for the
list we're switching to before invalidating filters. The next assembly
runs with a fresh `since` (defaultSince / oneMonthAgo) and the relay
re-streams the events the cache lost. Added a `clear` method on
EOSEByKey + EOSEAccountKey and a protected `clearEoseFor(key)` helper
on PerUserAndFollowListEoseManager.

Same shape of bug likely affects pictures/discovery/etc. that share this
assembler pattern — left those alone for now and can apply the same
fix if reported.
2026-05-19 21:58:35 +00:00
Claude d96209b20d fix(calendars): top-bar filter switch resets scroll; week/month/day headers scroll with the disappearing bar
Two related fixes for the calendar surface:

1. Filter switch leaving the user in the past section. The feed-content
   state already calls sendToTop() when feedKey changes, but the calendar
   feed's LazyColumn never subscribed to the scrollToTop signal — so a
   filter switch (e.g. People List → Global) preserved the previous
   scroll position. Often that landed the user mid-feed in the "Past"
   section of the much larger Global list, looking like "the events
   didn't come back". Added a LazyListState + WatchScrollToTop wiring.

2. Week/month/day headers stayed pinned when the DisappearingScaffold's
   top bar collapsed on scroll. The views had a Column with
   disappearingScaffoldPadding() that reserved a fixed top inset, so when
   the bar slid up the headers stayed put and a visual gap opened. Pulled
   the nav header (and for the week view, the WeekStrip + DaySummaryHeader)
   into the same LazyColumn as the events, with rememberFeedContentPadding
   as contentPadding — now the whole stack scrolls under the bar together.
2026-05-19 21:58:34 +00:00
Claude 638e22c262 fix(calendars): tighten ReactionsRow spacing on the detail screen
The outer scroll Column had verticalArrangement.spacedBy(16.dp), which
compounded with the ReactionsRow's own internal vertical padding to make
the row look like it had huge top/bottom margins (~22dp on each side).

Drop the blanket spacedBy and place spacing deliberately: small explicit
Spacers between divider-separated sections, none around the ReactionsRow
itself so its 6dp internal padding is what shows.
2026-05-19 21:58:34 +00:00
Claude fc1da467e7 feat(calendars): user-search participant picker + hide createdAt on cards
- The calendar event/collection cards' top user header showed the note's
  createdAt time-ago, which read as "the event time" at a glance — but
  it's actually the publication time. Added `showTimeAgo` to UserCardHeader
  and pass `false` from both calendar card surfaces so the only timestamp
  on the card is the event's own start/end.
- Replaced the New Calendar Event participant input (paste an npub or
  64-char hex) with the standard UserSuggestionState + ShowUserSuggestionList
  pattern used by the badge-award / DM / new-post screens. Typing a name,
  npub, hex, or NIP-05 surfaces matching users live; tapping one adds
  them.
2026-05-19 21:58:34 +00:00
Claude 5dfabf667f feat(calendars): route URL locations to the browser, not the maps app
NIP-52 \`location\` is free-text — some events stash a Zoom / Jitsi /
livestream URL there instead of a place name. The detail row was sending
every value through the geo: intent, which on most devices punts to the
maps app and renders the URL as a search query.

Now: if the trimmed value starts with http:// or https://, open it with
ACTION_VIEW on the URL itself (which the browser claims). Otherwise the
geo: path is unchanged. The leading icon and content description flip to
Link / "Open link" when it's a URL so the affordance is clear before the
tap.
2026-05-19 21:58:34 +00:00
Claude 0dea41602a fix(calendars): apply scaffold padding so month/week/day headers and collections list don't render behind the top app bar
The DisappearingScaffold places content at y=0 by design (so feeds scroll
behind the bar) and exposes the bar height via [LocalDisappearingScaffoldPadding]
for inner scrollables to consume. The static-headered grid views and the
collections list weren't reading that local, so:

- The week view's WeekStrip (day-number row) rendered behind the top bar.
- The month/year title and grid header sat behind the bar similarly.
- The collections LazyColumn's first card was clipped under the bar.

Fix:
- Add a small `Modifier.disappearingScaffoldPadding()` helper that applies
  the local padding. Month/week/day views opt in with one line.
- Collections LazyColumn uses `rememberFeedContentPadding(FeedPadding)`
  so cards inset on first render but still scroll behind the bar (matching
  the feed view's behaviour).
2026-05-19 21:58:34 +00:00
Claude 5116bc21ae refactor(calendars): extract DAL to commons + a11y + inline-FQN cleanup
DAL extraction (commons/src/jvmAndroid/.../model/nip52Calendar/):
- CalendarSortKeys, CalendarAppointmentView, MonthGridBars, IcsExport now
  live in commons so desktop and the future CLI can consume them. Package
  changed to com.vitorpamplona.amethyst.commons.model.nip52Calendar.
- IcsExportTest moved to commons/jvmTest so it can see the `internal`
  escapeText helper without exposing it as public API.
- Feed-filter classes (CalendarAppointmentsFeedFilter,
  CalendarCollectionsFeedFilter) stay in amethyst — they depend on Account
  and LocalCache. The ViewModels stay for the same reason; lifting them
  requires moving Account/LocalCache too, out of scope here.
- A few smart-cast call sites needed local bindings because cross-module
  properties don't support implicit smart-cast.

Accessibility:
- Each month-grid cell announces a full content description ("Wednesday,
  January 15, 2025, 2 events, today, selected") via mergeDescendants so
  TalkBack reads the cell as one item with role=Button.
- Week-strip cells get the same treatment with role=Tab.
- Header title now announces both the title and "jump to today" so the
  affordance is discoverable.
- The expanding FAB describes itself as a toggle, and each sub-FAB as
  the concrete create action.

Inline-FQN cleanup pass:
- CalendarEventDetailScreen (already in a prior pass), CalendarCollectionsView,
  NewCalendarEventScreen, NewCalendarCollectionScreen: hoisted
  fully-qualified androidx.compose / com.vitorpamplona references into
  proper imports per the codebase style.

All 56 calendar tests still pass (48 in amethyst + 8 IcsExport in commons).
2026-05-19 21:58:34 +00:00
Claude ab5d884b34 feat(calendars): "Add to phone calendar" intent + multi-day bars in month grid
- New IconButton in the detail screen opens the system event composer
  (Google Calendar / Samsung / iCloud) pre-filled with title, range,
  location, and description via Intent.ACTION_INSERT on CalendarContract.
  Falls back to the .ics share path if no calendar app is installed.
- Replaced the per-cell event-dot row with horizontal bars that span the
  cells a multi-day event covers. Bars are laid out by a greedy
  lowest-lane assignment so overlapping events stack rather than collide;
  cells removed their horizontal padding so adjacent bars merge into one
  uninterrupted line.
- Bars round their ends only at the event's actual start/end (or at week
  boundaries), so a 3-day conference reads as one continuous pill across
  the row.
- Added MonthGridBarsTest (7 tests) covering single/multi-day, lane
  collision, longer-event tiebreak, and the empty/ghost edge cases.
2026-05-19 21:58:34 +00:00
Claude 971e9e4846 feat(calendars): reactions/zaps row, multi-day events, swipe nav, prefetch
- ReactionsRow on the detail screen so zap/like/repost/reply use the same
  shared component every other note surface uses (no calendar-specific
  reinvention)
- Multi-day events now appear on every day they cover in month/week/day
  grids (groupByDayKeyExpanded), with "Day X of Y" / "Continues" markers
  on continuation rows
- Horizontal swipe gestures on month/week/day views via a shared
  calendarSwipeNavigation modifier
- Deep-link prefetch: notification taps on a calendar naddr route
  directly to CalendarEventDetail (skipping EventRedirect) and the
  detail screen issues a per-event relay subscription via observeNote
- "Happening now · ends in X" relative-time label for ongoing events
- Richer empty-state copy with a shared CalendarEmptyState composable
  (feed / day / week / collections each get a title + actionable hint)
- Cleanup: hoist inline fully-qualified imports across the detail screen
2026-05-19 21:58:34 +00:00