Files
amethyst/commons/ARCHITECTURE.md
T
Claude 09825950c7 docs: one UI for Android and Desktop, and the measured Wave 4 plan
Android now ships on laptops, so the whole UI (screens and the navigation
shell) moves to commonsUI, amethyst becomes an Android shim, and a new JVM
desktopApp replaces the current one with the same UI.

- commons/plans/2026-09-27-one-ui-android-desktop.md: the decision, what it
  supersedes in the sweep tracker, the prerequisites (androidx
  navigation-compose publishes only jvmStubs for JVM; the Amethyst.instance
  app root is read by 178 files; Android-only libraries inside screens), and
  Wave 4 measured: Account's move-group is 77 files / ~23.9k lines inside
  model/, with 5 hard-blocked files and 14 exit edges, each with a proposed
  cut; AccountViewModel's Android imports and 29 outside dependencies.
- CLAUDE.md, commons/ARCHITECTURE.md, commonsUI/ARCHITECTURE.md and the
  kotlin-multiplatform, compose-expert and desktop-expert skills no longer say
  screens and navigation stay platform-native.
- The sweep tracker marks its STAY list, Wave 2 part B and the Desktop-phase
  merges as superseded, and re-scopes Wave 4 from decomposition to moving
  Account and AccountViewModel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
2026-09-27 23:00:45 +00:00

22 KiB

commons — Shared Module Architecture & Goals

commons is the shared layer between every Amethyst front end:

Consumer Kind Uses from commons
amethyst Android app (phones, tablets, laptops) everything (models, state, ViewModels) + commonsUI
desktopApp Desktop JVM app everything (models, state, ViewModels) + commonsUI; today's mouse-first screens are to be replaced by the shared UI
cli (amy) Headless JVM CLI (no UI) everything — commons is headless by construction; it never sees commonsUI
nappletHost Android WebView sandbox napplet contract + commonsUI (for the shell/shim Compose resources)
iOS (future) iOS app everything + commonsUI; expected to share most UI with Android

commons sits above quartz (the protocol-only Nostr KMP library) and below the apps. The split between the three is:

  • quartz/ — Nostr protocol: events, NIPs, crypto, relay framing. No app state, no UI, no caches of "what this user follows."
  • commons/ — everything an Amethyst client needs that isn't platform-specific or Compose UI: domain models (Note, User), in-memory state holders, ViewModels, the relay-subscription client, shared business services.
  • commonsUI/ — the Compose UI components that more than one front end renders, plus everything only they need (icons, theme, Coil fetchers, markdown, the composeResources strings/fonts and the generated Res). Depends on commons as api. See commonsUI/ARCHITECTURE.md.
  • amethyst/ & desktopApp/ — platform shims: the Activity / Window entry points, services, system integration, and the platform implementations of shared ports. They assemble commons pieces; they should not re-implement them. Screens and navigation are being moved into commonsUI so Android (which now also runs on laptops) and a new JVM Desktop app render one UI; see plans/2026-09-27-one-ui-android-desktop.md.

The goal is "write it once in commons". Before adding a manager, cache, filter, ViewModel, or composable to an app module, check whether it already exists here or belongs here. See the "Where does my code go?" guide below.


1. The one rule that shapes the package tree: the UI / non-UI boundary

The shared layer is two modules with one package tree:

commons = CLI-safe code. It does not depend on Compose UI. It may use the androidx.compose.runtime annotations @Stable / @Immutable (they are just stability tags) and snapshot state (mutableStateOf, State), but it must not import androidx.compose.ui, androidx.compose.foundation, androidx.compose.material3, Coil, the generated Res, declare @Composable functions, or build ImageVectors. Its build.gradle.kts simply has none of those dependencies, so a violation fails to compile.

commonsUI = UI code. Anything that does the above. It is only usable by the GUI front ends (Android, Desktop, iOS), never by cli.

Both modules share the same com.vitorpamplona.amethyst.commons.* package tree — the split is a module boundary, not a package rename, so a file moves between commons/src/… and commonsUI/src/… without changing its package or any consumer's imports. Kotlin resolves same-package declarations across modules without imports; the only thing that stops working across the boundary is internal visibility (a UI file cannot see an internal declaration in commons — make it public or move it).

This boundary is not a top-level ui/ vs logic/ partition of the package tree (we chose to stay feature-oriented, §3). It is a property of each file: a feature keeps its logic in commons/…/<feature>/ and its composables in commonsUI/…/<feature>/ui/ (or commonsUI/…/<feature>/ for the historical flat packages), and the table in §2 says which module each package lives in.


2. Package taxonomy

Top-level packages under commonMain/.../commons/, grouped by concern. UI? marks whether the package contains Compose UI (and therefore lives in commonsUI, not here). "mixed" means the feature's logic is in commons and its composables in commonsUI, under the same package.

Domain models & data

Package UI? Purpose
model no¹ Core domain types (Note, User, Channel), thread assembly (ThreadAssembler, ThreadLevelCalculator, ReplyContext, replyingDirectlyTo), and per-NIP event model extensions in model/nipNN… subpackages. model/cache holds the in-memory event store: the ICacheProvider / ILocalCache ports and UserMetadataCache in commonMain, and the concrete LocalCache (plus AntiSpamFilter, CachePruner, CacheSearch and the LocalCacheHost app-shell port) in jvmAndroid. model/account, model/observables. The largest package; keep it organized by NIP.
defaults no Static bootstrap data (default relays, channels).

model/navigation also holds the headless navigation identifiers: NavBarItem, BottomBarEntry and the app's @Serializable Route catalog (Routes.kt). model/composer holds post-composer state that is not UI (AudienceSelection, SplitBuilder, IZapRaiser).

¹ model uses only the @Stable/@Immutable runtime annotations — CLI-safe.

Protocol-adjacent business logic (CLI-safe)

Package UI? Purpose
actions no Event builders for user actions (follow, zap…). The canonical entry point for non-UI callers.
account mixed New-account bootstrap events; the logged-off login/sign-up buttons in commonsUI account/ui/login and account/ui/signup.
onchain mixed On-chain zap splitting/broadcasting; user-facing failure strings in commonsUI onchain/ui.
marmot mixed MLS group-chat event processing; group-chat composables (retention picker, agent stream banner) in commonsUI marmot/ui.
cyberspace no CYBERSPACE_V2 §7.7 region-bag search: the free quote off a bag's hint/h tags, the device-measured budget, and the cold sweep flow over quartz's RegionSweep. The protocol itself (coordinates, Cantor trees, keys, the bag) is quartz/.../cyberspace.
sno mixed DECK-0003 object rendering math — rasterizer, lighting, face winding, default avatar — here; the Compose viewer/thumbnail and the Coil fetcher in commonsUI under sno and sno/ui.
nip53LiveActivities mixed Live-activity zapper aggregation (logic) + the stream card in nip53LiveActivities/ui.
search no Event search filtering/ranking, kind registry.
preview no OpenGraph / meta-tag link-preview parsing.
emojicoder no Variation-selector emoji encode/decode.
richtext no URL/media/pattern parsing for rich text.
blurhash no BlurHash encode/decode (pure math; platform image bridge in platform sets).
thumbhash no ThumbHash encode/decode.
nipACWebRtcCalls mixed NIP-AC WebRTC call state machine + peer-session abstraction. See nipACWebRtcCalls/ARCHITECTURE.md. (Mirrors quartz/.../nipACWebRtcCalls.) Call-screen previews in commonsUI nipACWebRtcCalls/ui.
qrcode mixed Scanned-payload classification (classifyScannedPayload: NIP-19, nostr: URIs, relays, NWC/LN) here; scanner sheets in commonsUI qrcode/ui.
scheduledposts mixed Scheduled-post model + signed-event parsers (extractFirstMediaUrl, extractEventId, extractContentPreview); the MediaThumbnail composable in commonsUI scheduledposts/ui.

State holders & ViewModels

Package UI? Purpose
state no² Small feature StateFlow machines (FollowState, UserMetadataState, LoadingState).
viewmodels no² Larger list/feed-backed ViewModels (androidx.lifecycle.ViewModel). Shared by all GUI front ends; cli usually drives the layers below instead. The few that hold Compose UI state (ChatNewMessageState — TextFieldValue; thread/LevelFeedViewModel — LazyListState) live in commonsUI under the same package.
feeds no The feed data-access layer at the root (FeedFilter, AdditiveFeedFilter, AdditiveComplexFeedFilter, ChangesFlowFilter, FeedContentState, FeedState, InvalidatableContent, DefaultFeedOrder, RepostRenderability…) plus feeds/custom (FeedDefinitionRepository — custom-feed definitions & ordering) and feeds/related. See the feed-patterns skill.
profile mixed ProfileBroadcastStatus (state) + EditProfileFields at the root; the ProfileBroadcastBanner composable lives in commonsUI profile/ui.
privacylock mixed Lock state machine + settings here; LocalPrivacyLockState/lockStateFor (CompositionLocal accessor) in commonsUI.

² may touch compose.runtime state types (snapshot state, @Stable); they are shared across the GUI apps. A state holder that needs a foundation/ui type (LazyListState, TextFieldValue, TextFieldState) goes to commonsUI.

Relay client

Package UI? Purpose
relayClient mixed Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a composeSubscriptionManagers subpackage name, this is subscription-lifecycle logic, not UI.) The @Composable entry points — relayClient/user/ (observeUser* — kind-0 metadata), relayClient/event/ (EventFinderFilterAssemblerSubscription/observeNote*), the other *FilterAssemblerSubscriptions, KeyDataSourceSubscription, auth/AuthApprovalBanner — are in commonsUI under the same packages. See the relay-client skill.
relays mixed Low-level EOSE/relay-timing bookkeeping (EOSECache, EOSERelayList); relay-list settings composables (DraggableRelayList, RelayEventCountRow, the NIP-45 count result types) in commonsUI relays/ui.

Platform abstractions (expect/actual)

Package UI? Purpose
util no KMP primitives: KmpLock, WeakReference, number/URL/codepoint helpers, list/debug helpers. This is the only general-utility package — there is no utils.
keystorage no Secure key storage interface (Keystore / keychain / keyring actuals).
tor no Tor manager interface + settings.
service no Cross-cutting services: BundledUpdate batching (common); service/upload (JVM), service/nwc, service/lnurl (jvmAndroid). Singular service — there is no services.

UI (Compose — lives in commonsUI)

Package UI? Purpose
ui yes Cross-cutting shared composables only, organized by area: ui/components, ui/theme, ui/signing, ui/thread, ui/note, ui/richtext, ui/search, ui/notifications, ui/screens, ui/layouts, ui/feeds (feed shell: empty/error/loading states, refresh box, remembered scroll states), ui/navigation (INav, EmptyNav, ObservableNav/TwoPaneNav/ShareToDMNav, top bars, drawer swipe), ui/settings (the searchable settings catalog), ui/markdown, ui/privacylock, plus Compose helpers in ui/state (cached-state) and ui/text (TextField extensions). Feature-specific UI lives in <feature>/ui, not here. Nothing under ui.* lives in this module any more: the feed DAL that used to sit in ui/feeds is now feeds/, and the reply-context logic that sat in ui/note (replyingDirectlyTo, ReplyContext) is now in model/ next to ThreadAssembler.
nip23LongContent yes Long-form (NIP-23) article UI: nip23LongContent/ui/article (reader) + …/ui/editor (authoring). The model lives in model/nip23LongContent (here).
icons yes ImageVector icon definitions + builders, Material Symbols codepoints, the icon-font glyph tables.
hashtags yes Custom hashtag ImageVectors.
robohash yes Procedural robohash avatar ImageVector assembly.
audio mixed Spectrum/visualizer data (AudioSpectrum, SpectrumAnalyzer…) here; the VisualizerRenderers, VisualizerRegistry and the canvas composables in commonsUI.
service/image mixed CoilImageBridge + the BlurHash/ThumbHash/Base64/Blossom Coil fetchers are commonsUI (they are Coil); the headless image helpers stay here.
napplet mixed Protocol/permission logic here; NappletWebContract (serves the shell/shim from composeResources) in commonsUI.
favorites, nip30CustomEmojis, nip34Git, nip85TrustedAssertions, nip53LiveActivities mixed Logic here; each feature's ui/ (or the flat FavoriteAppIcon, EmojiSuggestionState) in commonsUI.
chats yes Composables shared by every chat kind (DMs, public chats, relay groups, concord): unread badge, divisors, system messages, author line, send button, reply toggle, new-conversation screen. Chat models are in model/chats.
nip17Dm, nip23LongContent, nip28PublicChat, nip29RelayGroups, nip32Labeling, nip43RelayMembers, nip51Lists, nip52Calendar, nip56Reports, nip72ModCommunities, nip73ExternalIds, nip99Classifieds, nipC0CodeSnippets, nipCCGeocaching, birdstar, nipsOnNostr, buzz, cashu, clink, concord, music, mediaServers, nip46RemoteSigner, browser, profile, napplet yes / mixed Single-feature UI under <feature>/ui (named after the quartz package, or its concern name when quartz has none). Where the package also has logic (cashu, browser, napplet, profile) that part stays here; the buzz and concord models are in model/buzz and model/concord.

Mixed (documented debt — see §4)

Package UI? Purpose
nip64Chess mixed Live-chess feature: game/lobby/subscription logic here; board/lobby composables in commonsUI under nip64Chess/ui. (Mirrors quartz/.../nip64Chess.)
domain no Currently only domain/nip46 (Nostr Connect signer flows). Sparse; candidate to fold into a clearer home.

3. Conventions

Feature-oriented, not layer-partitioned

We keep a feature's model, state, and UI together under one feature package rather than splitting the whole module into top-level ui/ / viewmodels/ / model/ layers. Within a feature, separate UI from logic with a ui subpackage (e.g. profile/ProfileBroadcastStatus vs profile/ui/ProfileBroadcastBanner) so the CLI-safe boundary (§1) stays visible.

The big shared cross-feature packages (model, ui, relayClient, util, icons) are the exception — they are organized by layer because many features share them.

Naming

  • Singular, no synonyms. util (not utils), service (not services). One concept → one package name.
  • Feature UI vs cross-cutting UI — the deciding test. A composable goes in <feature>/ui if it renders/edits one feature's content (it would make no sense outside that feature) — e.g. profile/ui, nip53LiveActivities/ui, nip23LongContent/ui. It goes in ui/<area> only if it is reusable across features (theme, avatars, buttons, layouts, markdown rendering, shimmer…). When in doubt, ask "could a second, unrelated feature reuse this as-is?" — yes → ui/<area>, no → <feature>/ui. The top-level ui/ package holds no feature-specific composables.

NIP as the second axis (mirror quartz)

quartz is ~94% organized by NIP (nipNN<slug> per spec), and that is correct there — the protocol layer is naturally NIP-partitioned. commons is not organized by NIP at the top level, and should not be: most of it is cross-cutting infrastructure (ui, relayClient, viewmodels, feeds, util…) that serves many NIPs at once, and the load-bearing UI/non-UI boundary (§1) cuts across NIPs, so a NIP-first top level would just nest the same problem one level down.

Instead, layer is the primary axis, NIP is the secondary axis:

  • The big shared layers stay layer-organized (model, relayClient, the cross-cutting ui, …).
  • Inside a layer, NIP-specific code goes in a nipNN<slug> subpackage whose name matches quartz exactly — e.g. model/nip57Zaps. This gives a clean trace: quartz/nip57Zaps → commons/model/nip57Zaps.
  • A top-level package that is a single self-contained NIP feature takes the same name as its quartz counterpart, and owns its own UI under <feature>/ui: nip64Chess, nipACWebRtcCalls, nip53LiveActivities, nip23LongContent, marmot. (marmot is un-numbered in quartz too.) Generic/multi-NIP packages keep their concern name (search, preview, actions, richtext…).
  • Feature UI is never under ui/. A single-NIP feature's composables live in <feature>/ui (e.g. nip53LiveActivities/ui), not ui/nip53LiveActivities. ui/ is exclusively cross-cutting (§2, Naming).

Source sets

Source set For
commonMain KMP code for all targets (Android, JVM, iOS). Gated by verifyKmpPurity — no Jackson/OkHttp/System.currentTimeMillis/java.util.UUID/JVM @Synchronized/@Volatile. Use the KMP replacements.
jvmAndroid Shared by Android + Desktop, not iOS. Where JVM-bound deps live (nestsClient, OkHttp, NWC/LNURL).
jvmMain Desktop-only (keyring, EXIF, service/upload, OS notifications). dependsOn(jvmAndroid).
androidMain Android-only (Keystore, DataStore, the Android R string resources used by the napplet host). dependsOn(jvmAndroid).
iosMain iOS actuals. Compile-only spike today.

commonsUI mirrors the same source-set layout (plus skikoMain, shared by desktop JVM + iOS for org.jetbrains.skia pixel helpers); Coil-OkHttp, markdown and the viewModel() helper live in its jvmAndroid.

When adding platform code, prefer the most common source set that still compiles: commonMain → jvmAndroid → platform-specific. See /kotlin-multiplatform.

Where does my code go? (quick guide)

  1. Pure Nostr protocol (events/NIPs/crypto)? → not here, it's quartz.
  2. A composable (screens and navigation chrome included)? → commonsUI, in ui/<area> or <feature>/ui (same package tree as here). Also anything that imports Coil, Res, or a foundation/ui state type. Only the platform entry points and system integrations stay in amethyst//desktopApp/.
  3. A ViewModel / StateFlow state holder? → viewmodels or state (or <feature> if feature-scoped). Keep it CLI-safe where practical.
  4. Relay subscription / filter assembly? → relayClient.
  5. A domain model or per-NIP event wrapper? → model (model/nipNN…).
  6. A platform capability behind expect/actual (storage, crypto)? → the matching abstraction package + actuals in platform sets.
  7. A generic helper? → util. (Resist creating a new top-level package for one file.)

4. Known debt / follow-ups

These are intentionally documented, not silently tolerated. Fix opportunistically.

  • commons applies the Compose compiler plugin without declaring any composable. Deliberate, not debt: the plugin's @StabilityInferred stamps are what keep unannotated commons classes stable from the apps' point of view. Measured on full recompiles (2026-09-12): without it, unstable composable params go 20→28 in commonsUI, 33→65 in desktopApp, 90→149 in amethyst. Don't remove it; if a class must be stable for a hot path, annotate it explicitly as well.
  • Same package tree in two modules. Intentional (zero-import-churn split), but it means a package's module is not visible from its name. Rule of thumb: if it imports Compose UI it is in commonsUI; check §2 when unsure.
  • domain is sparse (only nip46). Either grow it as the home for use-case/flow types or rename it to the matching nip46RemoteSigner per the NIP-second-axis rule.
  • relays vs relayClient are coherent but close in name; relays is low-level EOSE bookkeeping, relayClient is the subscription client. Keep the distinction in mind when adding files.
  • Several single-file feature packages (account, marmot, nip53LiveActivities, keystorage) are kept as feature/abstraction namespaces expected to grow; do not fold them into util just for size.
  • onchain (on-chain zap splitting) is quartz-adjacent but un-numbered; leave readable unless a clear NIP number lands.
  • model/cache/EventCache is jvmAndroid, not commonMain. The NIP-95 java.io.File spill is the obvious blocker but not the binding one: the cache's own storage, LargeSoftCache, is jvmAndroid (WeakReference + ConcurrentSkipListMap), as are the two *ListMatchingFilter observables, MintDirectoryIndex and NwcPaymentTracker. Promoting the cache means promoting those first. The binding constraint is LargeSoftCache's sorted store: it backs the ranged forEach(from, to, …) that all of LargeSoftCacheAddressExt uses to scan one kind's slice of the Address key space, and a hash map turns each of those into a full scan. quartz/linuxTest/LargeCacheRangeFallbackTest documents the matching invariant from the other side — the native range overloads fall back to full scans, and that is deemed safe precisely because the range callers live in the JVM-only LargeSoftCache. Moving this needs a sorted KMP store first, not just the okio blob sink and the java.util.SortedSet signature change. See the audit in commons/plans/2026-08-30-commons-migration-sweep.md.

5. See also

  • nipACWebRtcCalls/ARCHITECTURE.md — WebRTC call state machine deep-dive.
  • Root .claude/CLAUDE.md — module overview, sharing philosophy, build commands.
  • /kotlin-multiplatform, /compose-expert, /feed-patterns, /relay-client, /account-state skills for the patterns referenced above.