Closes the last two items of documented debt from the commons/commonsUI split. ParentNote (replyingDirectlyTo, isCommunityDefinition) and ReplyContext are pure thread logic used by ViewModels, so they move from the misleading `ui.note` package to `commons.model`, next to ThreadAssembler, together with their tests and the StubCache fixture that shared the package. No `ui.*` package is left in commons. The `ui.note` composables in commonsUI gain explicit imports; consumer imports rewritten. Whether commons still needs the Compose compiler plugin was an open question; it is now measured. With compiler reports on the three GUI modules and full, non-incremental recompiles in both configurations, removing the plugin flips composable parameters typed with unannotated commons classes (TopFilter, TorSettings, ProfileBroadcastStatus, ScheduledPost, EmojiPackState, ...) from runtime-stable to unstable: 20→28 in commonsUI, 33→65 in desktopApp, 90→149 in amethyst. The plugin stays; the numbers are recorded in the build file, ARCHITECTURE.md and the split plan so the question is not reopened. Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug compiles for nappletHost and amethyst; commons/commonsUI/cli/desktopApp JVM test suites. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
18 KiB
commons — Shared Module Architecture & Goals
commons is the shared layer between every Amethyst front end:
| Consumer | Kind | Uses from commons |
|---|---|---|
amethyst |
Android app (touch-first) | everything (models, state, ViewModels) + commonsUI |
desktopApp |
Desktop JVM app (mouse-first) | everything (models, state, ViewModels) + commonsUI |
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 a platform-native screen, navigation shell, 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, thecomposeResourcesstrings/fonts and the generatedRes). Depends oncommonsasapi. SeecommonsUI/ARCHITECTURE.md.amethyst/&desktopApp/— platform-native screens, navigation (bottom-nav vs sidebar), gestures, system integration. They assemblecommonspieces; they should not re-implement them.
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 theandroidx.compose.runtimeannotations@Stable/@Immutable(they are just stability tags) and snapshot state (mutableStateOf,State), but it must not importandroidx.compose.ui,androidx.compose.foundation,androidx.compose.material3, Coil, the generatedRes, declare@Composablefunctions, or buildImageVectors. Itsbuild.gradle.ktssimply 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 bycli.
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 interfaces + UserMetadataCache. model/account, model/observables. The largest package; keep it organized by NIP. |
defaults |
no | Static bootstrap data (default relays, channels). |
¹ 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 |
no | New-account bootstrap events. |
onchain |
no | On-chain zap splitting/broadcasting. |
marmot |
no | MLS group-chat event processing. |
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 |
no | NIP-AC WebRTC call state machine + peer-session abstraction. See nipACWebRtcCalls/ARCHITECTURE.md. (Mirrors quartz/.../nipACWebRtcCalls.) |
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 |
no | Low-level EOSE/relay-timing bookkeeping (EOSECache, EOSERelayList). |
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/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. |
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(notutils),service(notservices). One concept → one package name. - Feature UI vs cross-cutting UI — the deciding test. A composable goes in
<feature>/uiif 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 inui/<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-levelui/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-cuttingui, …). - Inside a layer, NIP-specific code goes in a
nipNN<slug>subpackage whose name matchesquartzexactly — 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
quartzcounterpart, and owns its own UI under<feature>/ui:nip64Chess,nipACWebRtcCalls,nip53LiveActivities,nip23LongContent,marmot. (marmotis un-numbered inquartztoo.) 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), notui/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)
- Pure Nostr protocol (events/NIPs/crypto)? → not here, it's
quartz. - A composable rendered by ≥2 front ends, or that you want iOS to share? →
commonsUI, inui/<area>or<feature>/ui(same package tree as here). Also anything that imports Coil,Res, or afoundation/uistate type. - A ViewModel /
StateFlowstate holder? →viewmodelsorstate(or<feature>if feature-scoped). Keep it CLI-safe where practical. - Relay subscription / filter assembly? →
relayClient. - A domain model or per-NIP event wrapper? →
model(model/nipNN…). - A platform capability behind
expect/actual(storage, crypto)? → the matching abstraction package + actuals in platform sets. - 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.
commonsapplies the Compose compiler plugin without declaring any composable. Deliberate, not debt: the plugin's@StabilityInferredstamps 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 incommonsUI, 33→65 indesktopApp, 90→149 inamethyst. 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. domainis sparse (onlynip46). Either grow it as the home for use-case/flow types or rename it to the matchingnip46RemoteSignerper the NIP-second-axis rule.relaysvsrelayClientare coherent but close in name;relaysis low-level EOSE bookkeeping,relayClientis 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 intoutiljust for size. onchain(on-chain zap splitting) isquartz-adjacent but un-numbered; leave readable unless a clear NIP number lands.
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-stateskills for the patterns referenced above.