Files
Claude 5c0a5fa899 docs(agents): run the shared UI on the JVM to verify UI changes
Agents had no documented way to look at a UI change: compiling and unit
tests miss a screen that crashes on open, a missing string or a
collapsed layout. The desktop app renders the same commonsUI screens,
shell and feeds as Android, so running it is the cheap real check.

- tools/desktop-run/run-headless.sh: Xvfb + :desktopApp:run on a scratch
  profile, waits for the first paint, screenshots; logs/stop commands.
  Uses the software renderer, drops saved window bounds so the window
  fits the screen, and matches the app JVM without killing the caller.
- tools/desktop-run/Drive.java: pure-JDK java.awt.Robot driver
  (screenshot, click, type via clipboard, key chords, scroll, waitpaint),
  so no xdotool or window manager is needed.
- AGENTS.md: "Run the app before calling a UI change done".
- desktop-expert: headless run section, description trigger, and the
  test task is :desktopApp:test (plain JVM module), not jvmTest.
- compose-expert: pointer to the same loop.

Verified in a cloud container: login screen, npub login, feed, phone
tier at 480x900 with bottom bar, drawer + Escape, restart in ~18 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nKjeFL13yDUDtFxEHSUrr
2026-10-12 14:13:50 +00:00

8.5 KiB


name: compose-expert description: Shared Compose UI in commonsUI/: screens, components, the nav shell (ScreenLayoutSpec, AppShellLayout, Navigation 3 back stacks), component catalog, RichTextParser, Compose API/perf review. Use when writing or reviewing shared UI.

compose-expert

Where shared Compose UI lives in Amethyst and the house rules for it. What goes in commons vs commonsUI vs a platform shim is in AGENTS.md ("Sharing Philosophy", "Feature Workflow") and commonsUI/ARCHITECTURE.md; this skill does not repeat it.

Delegate: Activity / manifest / flavors -> android-expert; Window, tray, menu bar -> desktop-expert; source-set placement, expect/actual, extracting an Android composable -> kotlin-multiplatform; feeds and Account state -> account-state; Flow and coroutine scopes -> kotlin-coroutines.

Where things live

All paths below are under commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/.

What Where
Screens ui/screen/loggedIn/<feature>/ (and ui/screen/ for shared feed views)
Reusable components ui/components/ (catalog: references/shared-composables-catalog.md)
Feed scaffolding ui/feeds/ (RenderFeedContentState, RefresheableBox, FeedEmpty, LoadingFeed)
Note rendering ui/note/ (NoteCompose), rich text in ui/richtext/ + ui/components/RichTextViewer.kt
Theme ui/theme/ (AmethystColorScheme.kt palettes + ColorScheme.* tokens, ColorScheme.isLight, Shape.kt, Type.kt)
Window tiers ui/layouts/ScreenLayout.kt (ScreenLayoutSpec, LocalScreenLayout, FeedContentMaxWidth)
Shell + nav ui/app/AmethystApp.kt, ui/app/AppRoot.kt, ui/navigation/ (see below)
Platform ports for UI ui/platform/AppPlatform.kt (LocalAppPlatform), ui/components/PlatformPermission.kt
Icons icons/ (Amethyst glyph font) and icons/symbols/ (MaterialSymbols, AmethystIcons)
Robohash avatars robohash/ (CachedRobohash, RobohashAssembler, parts/)
Strings commonsUI/src/commonMain/composeResources/values/strings.xml -> Res.string.* (rules: AGENTS.md "Strings")

The Android app's AmethystTheme wrapper is still in amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt; the palettes it applies are shared.

The shell and window tiers

One shell for every window, chosen by size, not by platform (AGENTS.md "Navigation Shell"):

  • AmethystApp (ui/app/AmethystApp.kt) is the root both front ends call. Android's MainActivity passes AndroidAppRoot; the desktop window (AmethystDesktop.kt) passes a DesktopAppRoot. AppRoot (ui/app/AppRoot.kt) is the port each platform implements: createAccountViewModel, rememberScreenLayoutSpec, registerDestinations, ShellOverlay, NavigationEffects, ...
  • ScreenLayoutSpec.navigationStyle is BOTTOM_BAR, NAV_RAIL or PERMANENT_DRAWER; hasRoomForNotificationPanel docks the notification panel. isLargeScreen is "not the bottom bar". Read it with LocalScreenLayout.current, never with a platform check.
  • AppShellLayout (ui/navigation/shell/) lays out the bar / rail / drawer; ui/navigation/bottombars/ holds AppBottomBar and AppNavigationRail; ui/navigation/deck/DeckArea.kt adds the opt-in deck columns on windows of 1,200 dp+.

Navigation 3

The app owns its back stack as snapshot state; NavDisplay only renders it. No NavController, no graph. Full rules in references/navigation.md.

  • Routes: @Serializable sealed class Route in commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt. The back stack is saved through kotlinx-serialization, so every route argument must be serializable.
  • NavBackStacks (same package) holds stack (never empty, starts at Route.Home) and savedTabs (the root entry of each bottom-bar tab you left, so its ViewModels and scroll survive). A NavStackEntry's contentKey is "nav-$id": saved state and ViewModels key on it, so the same route opened twice is two screens.
  • Nav (ui/navigation/navs/Nav.kt) implements INav: nav, navDrawer, newStack, navBottomBar, popBack, popUpTo, each after the keyboard settles (ImeSettler).
  • Register a screen once with a builder from ui/navigation/host/NavDestinations.kt. Shared screens go in NavDestinations.sharedDestinations (ui/navigation/host/SharedDestinations.kt); Android-only ones in androidDestinations in amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt. The builder picks the motion and the reading-column cap:
// from SharedDestinations.kt
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Browser>(capWidth = false) { BrowserScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }

A route nobody registered fails the first time it is opened (the desktop installs an unavailable screen instead). Gate a menu item on canOpen(route) when a front end may lack it.

Composable conventions that differ from the default

  • Strings come from Res, read with stringRes(Res.string.x); no literal user-visible text in a shared composable, no Android R.string. Default label parameters are StringResource (see AddButton in ui/components/ActionButtons.kt).
  • UI-state collections are kotlinx.collections.immutable (ImmutableList, ImmutableMap, persistentListOf), an api dependency of commonsUI. The parser output (RichTextViewerState, ParagraphState) and the platform ports already use them; pass those, not List, into composables.
  • @Immutable / @Stable mark the state classes and port interfaces handed to composables (ScreenLayoutSpec, AppPlatform, PlatformPermission). Only mark a class you can keep truly immutable.
  • Theme-dependent values come from the color scheme: MaterialTheme.colorScheme.isLight (memoized in AmethystColorScheme.kt), not a hand-rolled luminance check.
  • Icons follow AGENTS.md "Icons": icons/*.kt compile into amethyst_icons.ttf and draw through AmethystIconGlyph; Material icons are MaterialSymbols from a subset font, so a new codepoint needs tools/material-symbols-subset/subset.sh. Robohash is the one ImageVector-built asset: references/icon-assets.md.
  • Parse rich text through CachedRichTextParser, never per recomposition: references/rich-text-parsing.md.
  • Permissions in shared UI go through an expect fun rememberXPermission(): PlatformPermission (see ui/components/LocationPermission.kt; the Android actual wraps Accompanist).

Reviewing a component's public API

For slot parameters, modifier placement, and the other API-shape checks, use references/api-style.md.

Performance: measure first

Feed jank in this app has been measured as GC-bound and RenderThread-bound, with composition a small share of frame time. Stability annotations, derivedStateOf and deferred reads only help when a trace shows composition is the cost. Profile a release-like build (the benchmark build type in amethyst/build.gradle.kts is minified and profileable) before you apply them, and measure again afterwards.

Compose compiler reports are configured only for :amethyst (composeCompiler { reportsDestination ... } near line 551 of amethyst/build.gradle.kts, written to amethyst/build/compose_compiler/). They cover only composables compiled in that module. Shared composables are compiled in :commonsUI, which has no such block. To inspect their skippability, add the block to commonsUI/build.gradle.kts temporarily, and don't commit it.

See it running

A screen change is not done until you've looked at it: tools/desktop-run/run-headless.sh start runs the desktop app, which is this same UI, on a virtual display and screenshots it. AMETHYST_SCREEN=480x900x24 shows the phone tier. See tools/desktop-run/README.md and desktop-expert.

References

  • references/navigation.md: back-stack operations, rendering, transitions, intents, tests
  • references/shared-composables-catalog.md: the reusable components and their signatures
  • references/rich-text-parsing.md: RichTextParser, segments, CachedRichTextParser, NIP-92 imeta
  • references/icon-assets.md: robohash avatars
  • references/api-style.md: component API review checklist (slots, modifiers)