mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
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
145 lines
8.5 KiB
Markdown
145 lines
8.5 KiB
Markdown
---
|
|
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:
|
|
|
|
```kotlin
|
|
// 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)
|