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
5.5 KiB
Split commons into commons (headless) + commonsUI (Compose)
Date: 2026-09-12 Status: shipped on this branch
Why
cli (amy) depends on :commons. Until now :commons declared Compose
UI, Coil, Compose resources, markdown and (on JVM) compose.desktop.currentOs
as dependencies, so every CLI distribution dragged ~40 MB of Compose + Skiko
jars it never loads (create-release.yml had a 200 MB budget "until commons
is split into core + ui modules"; BUILDING.md flagged the same for the
Homebrew bundle). The UI / non-UI boundary documented in
commons/ARCHITECTURE.md §1 was a convention enforced by nothing.
What
A new KMP module :commonsUI (same targets and source-set layout as
:commons, plus skikoMain) that api-depends on :commons and owns every
Compose-dependent file. :commons keeps only the Compose runtime
(@Stable/@Immutable, snapshot state) and lifecycle-viewmodel; it no
longer applies the org.jetbrains.compose plugin, has no composeResources,
no Coil, no markdown, no highlights, no desktop Compose.
Files keep their packages. The split is a build-graph boundary, not a
package rename: 231 files were git mv'd from commons/src/… to
commonsUI/src/… and not a single import changed in amethyst,
desktopApp, nappletHost or cli. The generated Res class stays at
com.vitorpamplona.amethyst.commons.resources for the same reason.
Consumers: amethyst, desktopApp, nappletHost (for
NappletWebContract, which reads the shell/shim from composeResources) and
benchmark (robohash) gained project(":commonsUI"). cli, geode,
marmotBench are untouched and must stay that way.
Method (reproducible)
- Classify every
.ktundercommons/srcas UI if it importsandroidx.compose.{ui,foundation,material3,animation},org.jetbrains.compose.*,coil3,org.jetbrains.skia,androidx.lifecycle.compose,…commons.resources, or declares@Composable. - Build the intra-module reference graph (imports + same-package simple-name
hits) and verify no headless file references a UI file. Two real hits
surfaced and were fixed by surgery rather than by moving logic into the UI
module:
richtext/GalleryParsercarried a vestigial@OptIn(ExperimentalLayoutApi::class)— dropped; it is pure logic.privacylock/PrivacyLockStatedeclared theLocalPrivacyLockStateCompositionLocal +lockStateFor()next to the state machine — the two Compose members moved tocommonsUI/…/privacylock/LocalPrivacyLockState.kt(same package).
- Additionally move the rest of the UI-only packages (
ui/**,icons,hashtags,robohash,<feature>/ui) unless a headless file depends on the file. That rule keeps the feed DAL (ui/feeds/FeedFilter,AdditiveFeedFilter,ChangesFlowFilter,FeedContentState,RepostRenderability,AdditiveComplexFeedFilter…) andui/note/ParentNote+ReplyContextincommons(ViewModels use them). - Tests follow their subject; test fixtures shared with headless tests
(
ui/note/StubCache) stay incommons. - Check
expect/actualpairs never straddle the boundary (none did) and that nointernaldeclaration is used across it (none was).
Verified
:commons:compileKotlinJvm,:commonsUI:compileKotlinJvm,:cli:compileKotlin,:desktopApp:compileKotlin,:nappletHost:compileDebugKotlin,:amethyst:compileFdroidDebugKotlin.:commons:jvmTest,:commonsUI:jvmTest,:cli:test,:commons:verifyKmpPurity,:commonsUI:verifyKmpPurity.:cliruntime classpath no longer containsorg.jetbrains.compose.ui,foundation,material3,skikoorcoil.
iOS targets could not be linked in the Linux CI container; the workflow runs
:commonsUI:iosSimulatorArm64Test + :commonsUI:compileTestKotlinIosArm64
next to the :commons ones on macOS.
Follow-ups (done in the same branch)
- CLI size budget tightened to 120 MB in
create-release.yml. Measured after the split (1.15.2, Linux x64): JVM tarball 55 MB, jlink image tarball 80 MB,lib/60 MB on disk — vs ~70 MB JVM tarball before. - Feed DAL moved out of
ui.feedsintocommons/…/feeds/(root of the existingfeedspackage, next tofeeds/custom). Consumer imports rewritten. - Chess composables moved to
nip64Chess/uiincommonsUI; the logic stays incommons/…/nip64Chess/. ui/note/ParentNote+ReplyContextmoved tocommons/…/model/next toThreadAssembler(with theStubCachetest fixture). Noui.*package is left incommons.
Decided: commons keeps the Compose compiler plugin
Measured rather than guessed. With composeCompiler { reportsDestination }
on the three GUI modules and full (non-incremental, --rerun) compiles in
both configurations, removing the plugin from commons flips composable
parameters typed with unannotated commons classes from runtime-stable to
unstable:
| Module | unstable params, plugin on | plugin off |
|---|---|---|
commonsUI |
20 | 28 |
desktopApp |
33 | 65 |
amethyst (fdroidDebug) |
90 | 149 |
The classes involved (TopFilter, TorSettings, TorServiceStatus,
ProfileBroadcastStatus, ScheduledPost, EmojiPackState,
Nip65RelayListState, PendingAuthApproval, UserSearchEngine, …) are
all-val classes with no @Stable/@Immutable annotation; the plugin infers
their stability and keeps inferring it as they evolve. Hand-annotating them
would reproduce today's result but rot silently the first time a var is
added, so the plugin stays. Recorded in commons/build.gradle.kts.